@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +155 -97
  3. package/bin/guardian.js +1345 -60
  4. package/config/README.md +59 -0
  5. package/config/profiles/landing-demo.yaml +16 -0
  6. package/package.json +21 -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 +568 -7
  18. package/src/guardian/attempt-registry.js +42 -1
  19. package/src/guardian/attempt-relevance.js +106 -0
  20. package/src/guardian/attempt.js +24 -0
  21. package/src/guardian/baseline.js +12 -4
  22. package/src/guardian/breakage-intelligence.js +1 -0
  23. package/src/guardian/ci-cli.js +121 -0
  24. package/src/guardian/ci-output.js +4 -3
  25. package/src/guardian/cli-summary.js +79 -92
  26. package/src/guardian/config-loader.js +162 -0
  27. package/src/guardian/drift-detector.js +100 -0
  28. package/src/guardian/enhanced-html-reporter.js +221 -4
  29. package/src/guardian/env-guard.js +127 -0
  30. package/src/guardian/failure-intelligence.js +173 -0
  31. package/src/guardian/first-run-profile.js +89 -0
  32. package/src/guardian/first-run.js +6 -1
  33. package/src/guardian/flag-validator.js +17 -3
  34. package/src/guardian/html-reporter.js +2 -0
  35. package/src/guardian/human-reporter.js +431 -0
  36. package/src/guardian/index.js +22 -19
  37. package/src/guardian/init-command.js +9 -5
  38. package/src/guardian/intent-detector.js +146 -0
  39. package/src/guardian/journey-definitions.js +132 -0
  40. package/src/guardian/journey-scan-cli.js +145 -0
  41. package/src/guardian/journey-scanner.js +583 -0
  42. package/src/guardian/junit-reporter.js +18 -1
  43. package/src/guardian/live-cli.js +95 -0
  44. package/src/guardian/live-scheduler-runner.js +137 -0
  45. package/src/guardian/live-scheduler.js +146 -0
  46. package/src/guardian/market-reporter.js +341 -81
  47. package/src/guardian/pattern-analyzer.js +348 -0
  48. package/src/guardian/policy.js +80 -3
  49. package/src/guardian/preset-loader.js +9 -6
  50. package/src/guardian/reality.js +1278 -117
  51. package/src/guardian/reporter.js +27 -41
  52. package/src/guardian/run-artifacts.js +212 -0
  53. package/src/guardian/run-cleanup.js +207 -0
  54. package/src/guardian/run-latest.js +90 -0
  55. package/src/guardian/run-list.js +211 -0
  56. package/src/guardian/scan-presets.js +100 -11
  57. package/src/guardian/selector-fallbacks.js +394 -0
  58. package/src/guardian/semantic-contact-finder.js +2 -1
  59. package/src/guardian/site-introspection.js +257 -0
  60. package/src/guardian/smoke.js +2 -2
  61. package/src/guardian/snapshot-schema.js +25 -1
  62. package/src/guardian/snapshot.js +46 -2
  63. package/src/guardian/stability-scorer.js +169 -0
  64. package/src/guardian/template-command.js +184 -0
  65. package/src/guardian/text-formatters.js +426 -0
  66. package/src/guardian/verdict.js +320 -0
  67. package/src/guardian/verdicts.js +74 -0
  68. package/src/guardian/watch-runner.js +3 -7
  69. package/src/payments/stripe-checkout.js +169 -0
  70. package/src/plans/plan-definitions.js +148 -0
  71. package/src/plans/plan-manager.js +211 -0
  72. package/src/plans/usage-tracker.js +210 -0
  73. package/src/recipes/recipe-engine.js +188 -0
  74. package/src/recipes/recipe-failure-analysis.js +159 -0
  75. package/src/recipes/recipe-registry.js +134 -0
  76. package/src/recipes/recipe-runtime.js +507 -0
  77. package/src/recipes/recipe-store.js +410 -0
  78. package/guardian-contract-v1.md +0 -149
  79. /package/{guardian.config.json → config/guardian.config.json} +0 -0
  80. /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
  81. /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
  82. /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
  83. /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
  84. /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 ? 'skipped' : 'failed';
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 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
+ }
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 };