@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.
Files changed (101) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +155 -97
  3. package/bin/guardian.js +1544 -55
  4. package/config/README.md +59 -0
  5. package/config/profiles/landing-demo.yaml +16 -0
  6. package/package.json +26 -11
  7. package/policies/landing-demo.json +22 -0
  8. package/src/enterprise/audit-logger.js +166 -0
  9. package/src/enterprise/pdf-exporter.js +267 -0
  10. package/src/enterprise/rbac-gate.js +142 -0
  11. package/src/enterprise/rbac.js +239 -0
  12. package/src/enterprise/site-manager.js +180 -0
  13. package/src/founder/feedback-system.js +156 -0
  14. package/src/founder/founder-tracker.js +213 -0
  15. package/src/founder/usage-signals.js +141 -0
  16. package/src/guardian/alert-ledger.js +121 -0
  17. package/src/guardian/attempt-engine.js +587 -12
  18. package/src/guardian/attempt-registry.js +42 -1
  19. package/src/guardian/attempt-relevance.js +106 -0
  20. package/src/guardian/attempt.js +85 -39
  21. package/src/guardian/attempts-filter.js +63 -0
  22. package/src/guardian/baseline.js +50 -8
  23. package/src/guardian/breakage-intelligence.js +1 -0
  24. package/src/guardian/browser-pool.js +131 -0
  25. package/src/guardian/browser.js +28 -1
  26. package/src/guardian/ci-cli.js +121 -0
  27. package/src/guardian/ci-mode.js +15 -0
  28. package/src/guardian/ci-output.js +38 -0
  29. package/src/guardian/cli-summary.js +167 -67
  30. package/src/guardian/config-loader.js +162 -0
  31. package/src/guardian/data-guardian-detector.js +189 -0
  32. package/src/guardian/detection-layers.js +271 -0
  33. package/src/guardian/drift-detector.js +100 -0
  34. package/src/guardian/enhanced-html-reporter.js +221 -4
  35. package/src/guardian/env-guard.js +127 -0
  36. package/src/guardian/failure-intelligence.js +173 -0
  37. package/src/guardian/first-run-profile.js +89 -0
  38. package/src/guardian/first-run.js +54 -0
  39. package/src/guardian/flag-validator.js +111 -0
  40. package/src/guardian/flow-executor.js +309 -44
  41. package/src/guardian/html-reporter.js +2 -0
  42. package/src/guardian/human-reporter.js +431 -0
  43. package/src/guardian/index.js +22 -19
  44. package/src/guardian/init-command.js +9 -5
  45. package/src/guardian/intent-detector.js +146 -0
  46. package/src/guardian/journey-definitions.js +132 -0
  47. package/src/guardian/journey-scan-cli.js +145 -0
  48. package/src/guardian/journey-scanner.js +583 -0
  49. package/src/guardian/junit-reporter.js +18 -1
  50. package/src/guardian/language-detection.js +99 -0
  51. package/src/guardian/live-cli.js +95 -0
  52. package/src/guardian/live-scheduler-runner.js +137 -0
  53. package/src/guardian/live-scheduler.js +146 -0
  54. package/src/guardian/market-reporter.js +357 -82
  55. package/src/guardian/parallel-executor.js +116 -0
  56. package/src/guardian/pattern-analyzer.js +348 -0
  57. package/src/guardian/policy.js +80 -3
  58. package/src/guardian/prerequisite-checker.js +101 -0
  59. package/src/guardian/preset-loader.js +27 -18
  60. package/src/guardian/profile-loader.js +96 -0
  61. package/src/guardian/reality.js +1612 -115
  62. package/src/guardian/reporter.js +27 -41
  63. package/src/guardian/run-artifacts.js +212 -0
  64. package/src/guardian/run-cleanup.js +207 -0
  65. package/src/guardian/run-latest.js +90 -0
  66. package/src/guardian/run-list.js +211 -0
  67. package/src/guardian/run-summary.js +20 -0
  68. package/src/guardian/scan-presets.js +100 -11
  69. package/src/guardian/selector-fallbacks.js +394 -0
  70. package/src/guardian/semantic-contact-detection.js +255 -0
  71. package/src/guardian/semantic-contact-finder.js +201 -0
  72. package/src/guardian/semantic-targets.js +234 -0
  73. package/src/guardian/site-introspection.js +257 -0
  74. package/src/guardian/smoke.js +258 -0
  75. package/src/guardian/snapshot-schema.js +25 -1
  76. package/src/guardian/snapshot.js +69 -3
  77. package/src/guardian/stability-scorer.js +169 -0
  78. package/src/guardian/success-evaluator.js +214 -0
  79. package/src/guardian/template-command.js +184 -0
  80. package/src/guardian/text-formatters.js +426 -0
  81. package/src/guardian/timeout-profiles.js +57 -0
  82. package/src/guardian/verdict.js +320 -0
  83. package/src/guardian/verdicts.js +74 -0
  84. package/src/guardian/wait-for-outcome.js +120 -0
  85. package/src/guardian/watch-runner.js +181 -0
  86. package/src/payments/stripe-checkout.js +169 -0
  87. package/src/plans/plan-definitions.js +148 -0
  88. package/src/plans/plan-manager.js +211 -0
  89. package/src/plans/usage-tracker.js +210 -0
  90. package/src/recipes/recipe-engine.js +188 -0
  91. package/src/recipes/recipe-failure-analysis.js +159 -0
  92. package/src/recipes/recipe-registry.js +134 -0
  93. package/src/recipes/recipe-runtime.js +507 -0
  94. package/src/recipes/recipe-store.js +410 -0
  95. package/guardian-contract-v1.md +0 -149
  96. /package/{guardian.config.json → config/guardian.config.json} +0 -0
  97. /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
  98. /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
  99. /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
  100. /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
  101. /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 < 2; 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 ? 'skipped' : 'failed';
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 and move on
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: stepDef.timeout || 5000,
475
+ timeout: adaptiveTimeout,
404
476
  state: stepDef.state || 'visible'
405
477
  });
406
478
  found = true;
407
479
  break;
408
480
  } catch (err) {
409
- // Try next selector
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
- throw new Error(`Element not found: ${stepDef.target}`);
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 };