@odavl/guardian 2.0.0 → 2.0.1

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 (172) hide show
  1. package/CHANGELOG.md +210 -210
  2. package/LICENSE +21 -21
  3. package/README.md +297 -184
  4. package/bin/guardian.js +2242 -2221
  5. package/config/README.md +59 -59
  6. package/config/guardian.config.json +54 -54
  7. package/config/guardian.policy.json +12 -12
  8. package/config/profiles/docs.yaml +18 -18
  9. package/config/profiles/ecommerce.yaml +17 -17
  10. package/config/profiles/landing-demo.yaml +16 -16
  11. package/config/profiles/marketing.yaml +18 -18
  12. package/config/profiles/saas.yaml +21 -21
  13. package/flows/example-login-flow.json +36 -36
  14. package/flows/example-signup-flow.json +44 -44
  15. package/package.json +124 -116
  16. package/policies/enterprise.json +12 -12
  17. package/policies/landing-demo.json +22 -22
  18. package/policies/saas.json +12 -12
  19. package/policies/startup.json +12 -12
  20. package/src/enterprise/audit-logger.js +166 -166
  21. package/src/enterprise/pdf-exporter.js +267 -267
  22. package/src/enterprise/rbac-gate.js +142 -142
  23. package/src/enterprise/rbac.js +239 -239
  24. package/src/enterprise/site-manager.js +180 -180
  25. package/src/founder/feedback-system.js +156 -156
  26. package/src/founder/founder-tracker.js +213 -213
  27. package/src/founder/usage-signals.js +141 -141
  28. package/src/guardian/action-hints.js +439 -439
  29. package/src/guardian/alert-ledger.js +121 -121
  30. package/src/guardian/artifact-sanitizer.js +56 -56
  31. package/src/guardian/attempt-engine.js +1069 -1029
  32. package/src/guardian/attempt-registry.js +267 -267
  33. package/src/guardian/attempt-relevance.js +106 -106
  34. package/src/guardian/attempt-reporter.js +513 -507
  35. package/src/guardian/attempt.js +274 -273
  36. package/src/guardian/attempts-filter.js +63 -63
  37. package/src/guardian/auto-attempt-builder.js +283 -283
  38. package/src/guardian/baseline-registry.js +177 -177
  39. package/src/guardian/baseline-reporter.js +143 -143
  40. package/src/guardian/baseline-storage.js +285 -285
  41. package/src/guardian/baseline.js +535 -534
  42. package/src/guardian/behavioral-signals.js +261 -261
  43. package/src/guardian/breakage-intelligence.js +224 -224
  44. package/src/guardian/browser-pool.js +131 -131
  45. package/src/guardian/browser.js +119 -119
  46. package/src/guardian/canonical-truth.js +308 -308
  47. package/src/guardian/ci-cli.js +121 -121
  48. package/src/guardian/ci-gate.js +96 -96
  49. package/src/guardian/ci-mode.js +15 -15
  50. package/src/guardian/ci-output.js +55 -38
  51. package/src/guardian/cli-summary.js +102 -102
  52. package/src/guardian/confidence-signals.js +251 -251
  53. package/src/guardian/config-loader.js +161 -161
  54. package/src/guardian/config-validator.js +285 -283
  55. package/src/guardian/coverage-model.js +239 -239
  56. package/src/guardian/coverage-packs.js +58 -58
  57. package/src/guardian/crawler.js +142 -142
  58. package/src/guardian/data-guardian-detector.js +189 -189
  59. package/src/guardian/decision-authority.js +746 -725
  60. package/src/guardian/detection-layers.js +271 -271
  61. package/src/guardian/determinism.js +146 -146
  62. package/src/guardian/discovery-engine.js +661 -661
  63. package/src/guardian/drift-detector.js +100 -100
  64. package/src/guardian/enhanced-html-reporter.js +522 -522
  65. package/src/guardian/env-guard.js +128 -127
  66. package/src/guardian/error-clarity.js +399 -399
  67. package/src/guardian/export-contract.js +196 -196
  68. package/src/guardian/fail-safe.js +212 -212
  69. package/src/guardian/failure-intelligence.js +173 -173
  70. package/src/guardian/failure-taxonomy.js +169 -169
  71. package/src/guardian/final-outcome.js +206 -206
  72. package/src/guardian/first-run-profile.js +89 -89
  73. package/src/guardian/first-run.js +65 -67
  74. package/src/guardian/flag-validator.js +111 -111
  75. package/src/guardian/flow-executor.js +641 -639
  76. package/src/guardian/flow-registry.js +67 -67
  77. package/src/guardian/honesty.js +394 -394
  78. package/src/guardian/html-reporter.js +416 -416
  79. package/src/guardian/human-intent-resolver.js +296 -296
  80. package/src/guardian/human-interaction-model.js +351 -351
  81. package/src/guardian/human-journey-context.js +184 -184
  82. package/src/guardian/human-navigator.js +544 -544
  83. package/src/guardian/human-reporter.js +435 -431
  84. package/src/guardian/index.js +226 -221
  85. package/src/guardian/init-command.js +143 -143
  86. package/src/guardian/intent-detector.js +148 -146
  87. package/src/guardian/journey-definitions.js +132 -132
  88. package/src/guardian/journey-scan-cli.js +142 -145
  89. package/src/guardian/journey-scanner.js +583 -583
  90. package/src/guardian/junit-reporter.js +281 -281
  91. package/src/guardian/language-detection.js +99 -99
  92. package/src/guardian/live-alert.js +56 -56
  93. package/src/guardian/live-baseline-compare.js +146 -146
  94. package/src/guardian/live-cli.js +95 -95
  95. package/src/guardian/live-guardian.js +210 -210
  96. package/src/guardian/live-scheduler-runner.js +137 -137
  97. package/src/guardian/live-scheduler-state.js +167 -168
  98. package/src/guardian/live-scheduler.js +146 -146
  99. package/src/guardian/live-state.js +110 -110
  100. package/src/guardian/market-criticality.js +335 -335
  101. package/src/guardian/market-reporter.js +577 -577
  102. package/src/guardian/network-trace.js +178 -178
  103. package/src/guardian/obs-logger.js +110 -110
  104. package/src/guardian/observed-capabilities.js +427 -427
  105. package/src/guardian/output-contract.js +154 -0
  106. package/src/guardian/output-readability.js +264 -264
  107. package/src/guardian/parallel-executor.js +116 -116
  108. package/src/guardian/path-safety.js +56 -56
  109. package/src/guardian/pattern-analyzer.js +348 -348
  110. package/src/guardian/policy.js +432 -434
  111. package/src/guardian/prelaunch-gate.js +193 -193
  112. package/src/guardian/prerequisite-checker.js +101 -101
  113. package/src/guardian/preset-loader.js +152 -157
  114. package/src/guardian/profile-loader.js +96 -96
  115. package/src/guardian/reality.js +3025 -2826
  116. package/src/guardian/realworld-scenarios.js +94 -94
  117. package/src/guardian/reporter.js +167 -167
  118. package/src/guardian/retry-policy.js +123 -123
  119. package/src/guardian/root-cause-analysis.js +171 -171
  120. package/src/guardian/rules-engine.js +558 -558
  121. package/src/guardian/run-artifacts.js +212 -212
  122. package/src/guardian/run-cleanup.js +207 -207
  123. package/src/guardian/run-export.js +522 -522
  124. package/src/guardian/run-latest.js +90 -90
  125. package/src/guardian/run-list.js +211 -211
  126. package/src/guardian/run-summary.js +20 -20
  127. package/src/guardian/runtime-root.js +246 -246
  128. package/src/guardian/safety.js +248 -248
  129. package/src/guardian/scan-presets.js +133 -149
  130. package/src/guardian/screenshot.js +152 -152
  131. package/src/guardian/secret-hygiene.js +44 -44
  132. package/src/guardian/selector-fallbacks.js +394 -394
  133. package/src/guardian/semantic-contact-detection.js +255 -255
  134. package/src/guardian/semantic-contact-finder.js +201 -201
  135. package/src/guardian/semantic-targets.js +234 -234
  136. package/src/guardian/site-intelligence.js +588 -588
  137. package/src/guardian/site-introspection.js +257 -257
  138. package/src/guardian/sitemap.js +225 -225
  139. package/src/guardian/smoke.js +283 -258
  140. package/src/guardian/snapshot-schema.js +177 -290
  141. package/src/guardian/snapshot.js +430 -397
  142. package/src/guardian/stability-scorer.js +169 -169
  143. package/src/guardian/success-evaluator.js +214 -214
  144. package/src/guardian/template-command.js +184 -184
  145. package/src/guardian/text-formatters.js +426 -426
  146. package/src/guardian/timeout-profiles.js +57 -57
  147. package/src/guardian/truth/attempt.contract.js +158 -0
  148. package/src/guardian/truth/decision.contract.js +275 -0
  149. package/src/guardian/truth/snapshot.contract.js +363 -0
  150. package/src/guardian/validators.js +323 -323
  151. package/src/guardian/verdict-card.js +474 -474
  152. package/src/guardian/verdict-clarity.js +298 -298
  153. package/src/guardian/verdict-policy.js +363 -363
  154. package/src/guardian/verdict.js +333 -333
  155. package/src/guardian/verdicts.js +79 -74
  156. package/src/guardian/visual-diff.js +247 -247
  157. package/src/guardian/wait-for-outcome.js +119 -119
  158. package/src/guardian/watch-runner.js +181 -181
  159. package/src/guardian/watchdog-diff.js +167 -167
  160. package/src/guardian/webhook.js +206 -206
  161. package/src/payments/stripe-checkout.js +169 -169
  162. package/src/plans/plan-definitions.js +148 -148
  163. package/src/plans/plan-manager.js +211 -211
  164. package/src/plans/usage-tracker.js +210 -210
  165. package/src/recipes/recipe-engine.js +188 -188
  166. package/src/recipes/recipe-failure-analysis.js +159 -159
  167. package/src/recipes/recipe-registry.js +134 -134
  168. package/src/recipes/recipe-runtime.js +507 -507
  169. package/src/recipes/recipe-store.js +410 -410
  170. package/SECURITY.md +0 -77
  171. package/VERSIONING.md +0 -100
  172. package/guardian-contract-v1.md +0 -502
@@ -1,1029 +1,1069 @@
1
- /**
2
- * Guardian Attempt Engine - PHASE 1 + PHASE 2
3
- * Executes a single user attempt and tracks outcome (SUCCESS, FAILURE, FRICTION, NOT_APPLICABLE, DISCOVERY_FAILED)
4
- * Phase 2: Soft failure detection via validators
5
- * Phase 3: Robust selector discovery with fallbacks
6
- */
7
-
8
- const fs = require('fs');
9
- const path = require('path');
10
- const { getAttemptDefinition } = require('./attempt-registry');
11
- const { runValidators, analyzeSoftFailures } = require('./validators');
12
- const { buildSelectorChain, findElement, detectFeature } = require('./selector-fallbacks');
13
-
14
- class AttemptEngine {
15
- constructor(options = {}) {
16
- this.attemptId = options.attemptId || 'default';
17
- this.timeout = options.timeout || 30000;
18
- this.frictionThresholds = options.frictionThresholds || {
19
- totalDurationMs: 2500, // Total attempt > 2.5s
20
- stepDurationMs: 1500, // Any single step > 1.5s
21
- retryCount: 1 // More than 1 retry = friction
22
- };
23
- this.maxStepRetries = typeof options.maxStepRetries === 'number'
24
- ? Math.max(1, options.maxStepRetries)
25
- : 2;
26
- }
27
-
28
- /**
29
- * Load attempt definition by ID (Phase 3 registry)
30
- */
31
- loadAttemptDefinition(attemptId) {
32
- return getAttemptDefinition(attemptId);
33
- }
34
-
35
- /**
36
- * Execute a single attempt
37
- * Returns: { outcome, steps, timings, friction, error, validators, softFailures }
38
- */
39
- async executeAttempt(page, attemptId, baseUrl, artifactsDir = null, validatorSpecs = null) {
40
- const attemptDef = this.loadAttemptDefinition(attemptId);
41
- if (!attemptDef) {
42
- throw new Error(`Attempt ${attemptId} not found`);
43
- }
44
-
45
- const startedAt = new Date();
46
- const steps = [];
47
- const frictionSignals = [];
48
- const consoleMessages = []; // Capture console messages for validators
49
- const consoleErrors = [];
50
- const pageErrors = [];
51
- let currentStep = null;
52
- let lastError = null;
53
- let frictionReasons = [];
54
- let frictionMetrics = {};
55
-
56
- // Capture console messages for soft failure detection
57
- const consoleHandler = (msg) => {
58
- consoleMessages.push({
59
- type: msg.type(), // 'log', 'error', 'warning', etc.
60
- text: msg.text(),
61
- location: msg.location()
62
- });
63
- if (msg.type() === 'error') {
64
- consoleErrors.push(msg.text());
65
- }
66
- };
67
-
68
- page.on('console', consoleHandler);
69
- const pageErrorHandler = (err) => {
70
- pageErrors.push(err.message || 'page error');
71
- };
72
- page.on('pageerror', pageErrorHandler);
73
-
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
-
86
- // Replace $BASEURL placeholder in all steps
87
- const processedSteps = attemptDef.baseSteps.map(step => {
88
- if (step.target && step.target === '$BASEURL') {
89
- return { ...step, target: baseUrl };
90
- }
91
- return step;
92
- });
93
-
94
- // Execute each step
95
- for (const stepDef of processedSteps) {
96
- currentStep = {
97
- id: stepDef.id,
98
- type: stepDef.type,
99
- target: stepDef.target,
100
- description: stepDef.description,
101
- startedAt: new Date().toISOString(),
102
- retries: 0,
103
- status: 'pending',
104
- error: null,
105
- screenshots: []
106
- };
107
-
108
- const stepStartTime = Date.now();
109
-
110
- try {
111
- // Execute with retry logic (up to 2 attempts)
112
- let success = false;
113
- for (let attempt = 0; attempt < this.maxStepRetries; attempt++) {
114
- try {
115
- if (attempt > 0) {
116
- currentStep.retries++;
117
- // Small backoff before retry
118
- await page.waitForTimeout(200);
119
- }
120
-
121
- await this._executeStep(page, stepDef);
122
- success = true;
123
- break;
124
- } catch (err) {
125
- if (attempt === this.maxStepRetries - 1) {
126
- throw err; // Final attempt failed
127
- }
128
- // Retry on first failure
129
- }
130
- }
131
-
132
- const stepEndTime = Date.now();
133
- const stepDurationMs = stepEndTime - stepStartTime;
134
-
135
- currentStep.endedAt = new Date().toISOString();
136
- currentStep.durationMs = stepDurationMs;
137
- currentStep.status = 'success';
138
-
139
- // Check for friction signals in step timing
140
- if (stepDurationMs > this.frictionThresholds.stepDurationMs) {
141
- frictionSignals.push({
142
- id: 'slow_step_execution',
143
- description: `Step took longer than threshold`,
144
- metric: 'stepDurationMs',
145
- threshold: this.frictionThresholds.stepDurationMs,
146
- observedValue: stepDurationMs,
147
- affectedStepId: stepDef.id,
148
- severity: 'medium'
149
- });
150
- frictionReasons.push(`Step "${stepDef.id}" took ${stepDurationMs}ms (threshold: ${this.frictionThresholds.stepDurationMs}ms)`);
151
- }
152
-
153
- if (currentStep.retries > this.frictionThresholds.retryCount) {
154
- frictionSignals.push({
155
- id: 'multiple_retries_required',
156
- description: `Step required multiple retry attempts`,
157
- metric: 'retryCount',
158
- threshold: this.frictionThresholds.retryCount,
159
- observedValue: currentStep.retries,
160
- affectedStepId: stepDef.id,
161
- severity: 'high'
162
- });
163
- frictionReasons.push(`Step "${stepDef.id}" required ${currentStep.retries} retries`);
164
- }
165
-
166
- // Capture screenshot on success if artifacts dir provided
167
- if (artifactsDir) {
168
- const screenshotPath = await this._captureScreenshot(
169
- page,
170
- artifactsDir,
171
- stepDef.id
172
- );
173
- if (screenshotPath) {
174
- currentStep.screenshots.push(screenshotPath);
175
- }
176
- }
177
-
178
- } catch (err) {
179
- currentStep.endedAt = new Date().toISOString();
180
- currentStep.durationMs = Date.now() - stepStartTime;
181
- currentStep.status = stepDef.optional ? 'optional_failed' : 'failed';
182
- currentStep.error = err.message;
183
-
184
- if (stepDef.optional) {
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
- }
210
- steps.push(currentStep);
211
- continue;
212
- }
213
-
214
- lastError = err;
215
-
216
- // Capture screenshot and DOM on failure
217
- if (artifactsDir) {
218
- const screenshotPath = await this._captureScreenshot(
219
- page,
220
- artifactsDir,
221
- `${stepDef.id}_failure`
222
- );
223
- if (screenshotPath) {
224
- currentStep.screenshots.push(screenshotPath);
225
- }
226
- const domPath = await this._savePageContent(page, artifactsDir, `${stepDef.id}_failure`);
227
- if (domPath) {
228
- currentStep.domPath = domPath;
229
- }
230
- }
231
-
232
- throw err; // Stop attempt on step failure
233
- }
234
-
235
- steps.push(currentStep);
236
- }
237
-
238
- // All steps successful, now check success conditions
239
- const endedAt = new Date();
240
- const totalDurationMs = endedAt.getTime() - startedAt.getTime();
241
-
242
- // Check success conditions
243
- let successMet = false;
244
- let successReason = null;
245
-
246
- for (const condition of attemptDef.successConditions) {
247
- try {
248
- if (condition.type === 'url') {
249
- const currentUrl = page.url();
250
- if (condition.pattern.test(currentUrl)) {
251
- successMet = true;
252
- successReason = `URL matched: ${currentUrl}`;
253
- break;
254
- }
255
- } else if (condition.type === 'selector') {
256
- // Wait briefly for selector to become visible
257
- try {
258
- await page.waitForSelector(condition.target, { timeout: 3000, state: 'visible' });
259
- successMet = true;
260
- successReason = `Success element visible: ${condition.target}`;
261
- break;
262
- } catch (e) {
263
- // Continue to next condition
264
- }
265
- }
266
- } catch (err) {
267
- // Continue to next condition
268
- }
269
- }
270
-
271
- if (!successMet) {
272
- page.removeListener('console', consoleHandler);
273
- page.removeListener('pageerror', pageErrorHandler);
274
- return {
275
- outcome: 'FAILURE',
276
- steps,
277
- startedAt: startedAt.toISOString(),
278
- endedAt: endedAt.toISOString(),
279
- totalDurationMs,
280
- friction: {
281
- isFriction: false,
282
- signals: [],
283
- summary: null,
284
- reasons: [],
285
- thresholds: this.frictionThresholds,
286
- metrics: {}
287
- },
288
- error: 'Success conditions not met after all steps completed',
289
- successReason: null,
290
- validators: [],
291
- softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
292
- discoverySignals: {
293
- consoleErrorCount: consoleErrors.length,
294
- pageErrorCount: pageErrors.length
295
- }
296
- };
297
- }
298
-
299
- // Run validators for soft failure detection (Phase 2)
300
- let validatorResults = [];
301
- let softFailureAnalysis = { hasSoftFailure: false, failureCount: 0, warnCount: 0 };
302
-
303
- if (validatorSpecs && validatorSpecs.length > 0) {
304
- const validatorContext = {
305
- page,
306
- consoleMessages,
307
- url: page.url()
308
- };
309
-
310
- validatorResults = await runValidators(validatorSpecs, validatorContext);
311
- softFailureAnalysis = analyzeSoftFailures(validatorResults);
312
-
313
- // If validators detected soft failures, upgrade outcome
314
- if (softFailureAnalysis.hasSoftFailure) {
315
- // Soft failure still counts as FAILURE (outcome), not FRICTION
316
- // Soft failures are recorded separately for analysis
317
- }
318
- }
319
-
320
- // Check for friction signals in total duration
321
- if (totalDurationMs > this.frictionThresholds.totalDurationMs) {
322
- frictionSignals.push({
323
- id: 'slow_total_duration',
324
- description: `Total attempt duration exceeded threshold`,
325
- metric: 'totalDurationMs',
326
- threshold: this.frictionThresholds.totalDurationMs,
327
- observedValue: totalDurationMs,
328
- affectedStepId: null,
329
- severity: 'low'
330
- });
331
- frictionReasons.push(`Attempt took ${totalDurationMs}ms total (threshold: ${this.frictionThresholds.totalDurationMs}ms)`);
332
- }
333
-
334
- frictionMetrics = {
335
- totalDurationMs,
336
- stepCount: steps.length,
337
- totalRetries: steps.reduce((sum, s) => sum + s.retries, 0),
338
- maxStepDurationMs: Math.max(...steps.map(s => s.durationMs || 0))
339
- };
340
-
341
- // Determine outcome based on friction signals
342
- const isFriction = frictionSignals.length > 0;
343
- const outcome = isFriction ? 'FRICTION' : 'SUCCESS';
344
-
345
- // Generate friction summary
346
- const frictionSummary = isFriction
347
- ? `User succeeded, but encountered ${frictionSignals.length} friction ${frictionSignals.length === 1 ? 'signal' : 'signals'}`
348
- : null;
349
-
350
- return {
351
- outcome,
352
- steps,
353
- startedAt: startedAt.toISOString(),
354
- endedAt: endedAt.toISOString(),
355
- totalDurationMs,
356
- friction: {
357
- isFriction,
358
- signals: frictionSignals,
359
- summary: frictionSummary,
360
- reasons: frictionReasons, // Keep for backward compatibility
361
- thresholds: this.frictionThresholds,
362
- metrics: frictionMetrics
363
- },
364
- error: null,
365
- successReason,
366
- validators: validatorResults,
367
- softFailures: softFailureAnalysis,
368
- discoverySignals: {
369
- consoleErrorCount: consoleErrors.length,
370
- pageErrorCount: pageErrors.length
371
- }
372
- };
373
-
374
- } catch (err) {
375
- const endedAt = new Date();
376
- page.removeListener('console', consoleHandler);
377
- page.removeListener('pageerror', pageErrorHandler);
378
- return {
379
- outcome: 'FAILURE',
380
- steps,
381
- startedAt: startedAt.toISOString(),
382
- endedAt: endedAt.toISOString(),
383
- totalDurationMs: endedAt.getTime() - startedAt.getTime(),
384
- friction: {
385
- isFriction: false,
386
- reasons: [],
387
- thresholds: this.frictionThresholds,
388
- metrics: {}
389
- },
390
- error: `Step "${currentStep?.id}" failed: ${err.message}`,
391
- successReason: null,
392
- validators: [],
393
- softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
394
- discoverySignals: {
395
- consoleErrorCount: consoleErrors.length,
396
- pageErrorCount: pageErrors.length
397
- }
398
- };
399
- } finally {
400
- page.removeListener('console', consoleHandler);
401
- page.removeListener('pageerror', pageErrorHandler);
402
- }
403
- }
404
-
405
- /**
406
- * Execute a single step
407
- */
408
- async _executeStep(page, stepDef) {
409
- const timeout = stepDef.timeout || this.timeout;
410
-
411
- switch (stepDef.type) {
412
- case 'navigate':
413
- await page.goto(stepDef.target, {
414
- waitUntil: 'domcontentloaded',
415
- timeout
416
- });
417
- break;
418
-
419
- case 'click':
420
- // Try each selector in the target (semicolon-separated)
421
- const selectors = stepDef.target.split(',').map(s => s.trim());
422
- let clicked = false;
423
-
424
- for (const selector of selectors) {
425
- try {
426
- await page.click(selector, { timeout: 5000 });
427
- clicked = true;
428
- break;
429
- } catch (err) {
430
- // Try next selector
431
- }
432
- }
433
-
434
- if (!clicked) {
435
- throw new Error(`Could not click element: ${stepDef.target}`);
436
- }
437
-
438
- // Wait for navigation if expected
439
- if (stepDef.waitForNavigation) {
440
- await page.waitForLoadState('domcontentloaded').catch(() => {});
441
- }
442
- break;
443
-
444
- case 'type':
445
- // Try each selector
446
- const typeSelectors = stepDef.target.split(',').map(s => s.trim());
447
- let typed = false;
448
-
449
- for (const selector of typeSelectors) {
450
- try {
451
- await page.fill(selector, stepDef.value, { timeout: 5000 });
452
- typed = true;
453
- break;
454
- } catch (err) {
455
- // Try next selector
456
- }
457
- }
458
-
459
- if (!typed) {
460
- throw new Error(`Could not type into element: ${stepDef.target}`);
461
- }
462
- break;
463
-
464
- case 'waitFor':
465
- const waitSelectors = stepDef.target.split(',').map(s => s.trim());
466
- let found = false;
467
- let earlyExitReason = null;
468
-
469
- for (const selector of waitSelectors) {
470
- try {
471
- // Phase 7.4: Adaptive timeout
472
- const adaptiveTimeout = stepDef.timeout || 5000;
473
-
474
- await page.waitForSelector(selector, {
475
- timeout: adaptiveTimeout,
476
- state: stepDef.state || 'visible'
477
- });
478
- found = true;
479
- break;
480
- } catch (err) {
481
- // Phase 7.4: Detect early exit signals
482
- if (err.message && err.message.includes('Timeout')) {
483
- earlyExitReason = 'Target never appeared (DOM settled)';
484
- }
485
- }
486
- }
487
-
488
- if (!found) {
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);
494
- }
495
- break;
496
-
497
- case 'wait':
498
- await page.waitForTimeout(stepDef.duration || 1000);
499
- break;
500
-
501
- default:
502
- throw new Error(`Unknown step type: ${stepDef.type}`);
503
- }
504
- }
505
-
506
- /**
507
- * Capture screenshot
508
- */
509
- async _captureScreenshot(page, artifactsDir, stepId) {
510
- try {
511
- const screenshotsDir = path.join(artifactsDir, 'attempt-screenshots');
512
- if (!fs.existsSync(screenshotsDir)) {
513
- fs.mkdirSync(screenshotsDir, { recursive: true });
514
- }
515
-
516
- const filename = `${stepId}.jpeg`;
517
- const fullPath = path.join(screenshotsDir, filename);
518
-
519
- await page.screenshot({
520
- path: fullPath,
521
- type: 'jpeg',
522
- quality: 80,
523
- fullPage: true
524
- });
525
-
526
- return filename;
527
- } catch (err) {
528
- return null;
529
- }
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
- }
1027
- }
1028
-
1029
- module.exports = { AttemptEngine };
1
+ /**
2
+ * Guardian Attempt Engine - PHASE 1 + PHASE 2
3
+ * Executes a single user attempt and tracks outcome (SUCCESS, FAILURE, FRICTION, NOT_APPLICABLE, DISCOVERY_FAILED)
4
+ * Phase 2: Soft failure detection via validators
5
+ * Phase 3: Robust selector discovery with fallbacks
6
+ *
7
+ * @typedef {import('./truth/attempt.contract.js').AttemptResult} AttemptResult
8
+ * @typedef {import('./truth/attempt.contract.js').AttemptStep} AttemptStep
9
+ * @typedef {import('./truth/attempt.contract.js').FrictionAnalysis} FrictionAnalysis
10
+ * @typedef {import('./truth/attempt.contract.js').AttemptOutcome} AttemptOutcome
11
+ * @typedef {import('./truth/attempt.contract.js').StepStatus} StepStatus
12
+ * @typedef {import('./truth/attempt.contract.js').FrictionSeverity} FrictionSeverity
13
+ * @typedef {import('./truth/attempt.contract.js').FrictionMetrics} FrictionMetrics
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { getAttemptDefinition } = require('./attempt-registry');
19
+ const { runValidators, analyzeSoftFailures } = require('./validators');
20
+ const { buildSelectorChain, findElement, detectFeature } = require('./selector-fallbacks');
21
+
22
+ class AttemptEngine {
23
+ constructor(options = {}) {
24
+ this.attemptId = options.attemptId || 'default';
25
+ this.timeout = options.timeout || 30000;
26
+ this.frictionThresholds = options.frictionThresholds || {
27
+ totalDurationMs: 2500, // Total attempt > 2.5s
28
+ stepDurationMs: 1500, // Any single step > 1.5s
29
+ retryCount: 1 // More than 1 retry = friction
30
+ };
31
+ this.maxStepRetries = typeof options.maxStepRetries === 'number'
32
+ ? Math.max(1, options.maxStepRetries)
33
+ : 2;
34
+ }
35
+
36
+ /**
37
+ * Load attempt definition by ID (Phase 3 registry)
38
+ */
39
+ loadAttemptDefinition(attemptId) {
40
+ return getAttemptDefinition(attemptId);
41
+ }
42
+
43
+ /**
44
+ * Execute a single attempt
45
+ * @param {Object} page - Playwright page object
46
+ * @param {string} attemptId - Attempt identifier
47
+ * @param {string} baseUrl - Base URL for attempt
48
+ * @param {string|null} artifactsDir - Artifacts directory path
49
+ * @param {Array|null} validatorSpecs - Validator specifications
50
+ * @returns {Promise<AttemptResult>} Attempt execution result
51
+ */
52
+ async executeAttempt(page, attemptId, baseUrl, artifactsDir = null, validatorSpecs = null) {
53
+ const attemptDef = this.loadAttemptDefinition(attemptId);
54
+ if (!attemptDef) {
55
+ throw new Error(`Attempt ${attemptId} not found`);
56
+ }
57
+
58
+ const startedAt = new Date();
59
+ const steps = [];
60
+ const frictionSignals = [];
61
+ const consoleMessages = []; // Capture console messages for validators
62
+ const consoleErrors = [];
63
+ const pageErrors = [];
64
+ let currentStep = null;
65
+ let lastError = null;
66
+ const frictionReasons = [];
67
+ let frictionMetrics = {};
68
+
69
+ // Capture console messages for soft failure detection
70
+ const consoleHandler = (msg) => {
71
+ consoleMessages.push({
72
+ type: msg.type(), // 'log', 'error', 'warning', etc.
73
+ text: msg.text(),
74
+ location: msg.location()
75
+ });
76
+ if (msg.type() === 'error') {
77
+ consoleErrors.push(msg.text());
78
+ }
79
+ };
80
+
81
+ page.on('console', consoleHandler);
82
+ const pageErrorHandler = (err) => {
83
+ pageErrors.push(err.message || 'page error');
84
+ };
85
+ page.on('pageerror', pageErrorHandler);
86
+
87
+ try {
88
+ // Custom universal attempts bypass base step execution and implement purpose-built logic
89
+ if (attemptId === 'site_smoke') {
90
+ return await this._runSiteSmokeAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors);
91
+ }
92
+ if (attemptId === 'primary_ctas') {
93
+ return await this._runPrimaryCtasAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors);
94
+ }
95
+ if (attemptId === 'contact_discovery_v2') {
96
+ return await this._runContactDiscoveryAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors);
97
+ }
98
+
99
+ // Replace $BASEURL placeholder in all steps
100
+ const processedSteps = attemptDef.baseSteps.map(step => {
101
+ if (step.target && step.target === '$BASEURL') {
102
+ return { ...step, target: baseUrl };
103
+ }
104
+ return step;
105
+ });
106
+
107
+ // Execute each step
108
+ for (const stepDef of processedSteps) {
109
+ currentStep = {
110
+ id: stepDef.id,
111
+ type: stepDef.type,
112
+ target: stepDef.target,
113
+ description: stepDef.description,
114
+ startedAt: new Date().toISOString(),
115
+ retries: 0,
116
+ status: /** @type {StepStatus} */ ('pending'),
117
+ error: null,
118
+ screenshots: []
119
+ };
120
+
121
+ const stepStartTime = Date.now();
122
+
123
+ try {
124
+ // Execute with retry logic (up to 2 attempts)
125
+ const success = false;
126
+ for (let attempt = 0; attempt < this.maxStepRetries; attempt++) {
127
+ try {
128
+ if (attempt > 0) {
129
+ currentStep.retries++;
130
+ // Small backoff before retry
131
+ await page.waitForTimeout(200);
132
+ }
133
+
134
+ await this._executeStep(page, stepDef);
135
+ break;
136
+ } catch (err) {
137
+ if (attempt === this.maxStepRetries - 1) {
138
+ throw err; // Final attempt failed
139
+ }
140
+ // Retry on first failure
141
+ }
142
+ }
143
+
144
+ const stepEndTime = Date.now();
145
+ const stepDurationMs = stepEndTime - stepStartTime;
146
+
147
+ currentStep.endedAt = new Date().toISOString();
148
+ currentStep.durationMs = stepDurationMs;
149
+ currentStep.status = /** @type {StepStatus} */ ('success');
150
+
151
+ // Check for friction signals in step timing
152
+ if (stepDurationMs > this.frictionThresholds.stepDurationMs) {
153
+ frictionSignals.push({
154
+ id: 'slow_step_execution',
155
+ description: `Step took longer than threshold`,
156
+ metric: 'stepDurationMs',
157
+ threshold: this.frictionThresholds.stepDurationMs,
158
+ observedValue: stepDurationMs,
159
+ affectedStepId: stepDef.id,
160
+ severity: /** @type {FrictionSeverity} */ ('medium')
161
+ });
162
+ frictionReasons.push(`Step "${stepDef.id}" took ${stepDurationMs}ms (threshold: ${this.frictionThresholds.stepDurationMs}ms)`);
163
+ }
164
+
165
+ if (currentStep.retries > this.frictionThresholds.retryCount) {
166
+ frictionSignals.push({
167
+ id: 'multiple_retries_required',
168
+ description: `Step required multiple retry attempts`,
169
+ metric: 'retryCount',
170
+ threshold: this.frictionThresholds.retryCount,
171
+ observedValue: currentStep.retries,
172
+ affectedStepId: stepDef.id,
173
+ severity: /** @type {FrictionSeverity} */ ('high')
174
+ });
175
+ frictionReasons.push(`Step "${stepDef.id}" required ${currentStep.retries} retries`);
176
+ }
177
+
178
+ // Capture screenshot on success if artifacts dir provided
179
+ if (artifactsDir) {
180
+ const screenshotPath = await this._captureScreenshot(
181
+ page,
182
+ artifactsDir,
183
+ stepDef.id
184
+ );
185
+ if (screenshotPath) {
186
+ currentStep.screenshots.push(screenshotPath);
187
+ }
188
+ }
189
+
190
+ } catch (err) {
191
+ currentStep.endedAt = new Date().toISOString();
192
+ currentStep.durationMs = Date.now() - stepStartTime;
193
+ currentStep.status = /** @type {StepStatus} */ (stepDef.optional ? 'optional_failed' : 'failed');
194
+ currentStep.error = err.message;
195
+
196
+ if (stepDef.optional) {
197
+ // Optional steps should not fail the attempt; record soft failure
198
+ frictionSignals.push({
199
+ id: 'optional_step_failed',
200
+ description: `Optional step failed: ${stepDef.id}`,
201
+ metric: 'optionalStep',
202
+ threshold: 0,
203
+ observedValue: 1,
204
+ affectedStepId: stepDef.id,
205
+ severity: /** @type {FrictionSeverity} */ ('low')
206
+ });
207
+ frictionReasons.push(`Optional step failed and was skipped: ${stepDef.id}`);
208
+ if (artifactsDir) {
209
+ const screenshotPath = await this._captureScreenshot(
210
+ page,
211
+ artifactsDir,
212
+ `${stepDef.id}_optional_failure`
213
+ );
214
+ if (screenshotPath) {
215
+ currentStep.screenshots.push(screenshotPath);
216
+ }
217
+ const domPath = await this._savePageContent(page, artifactsDir, `${stepDef.id}_optional_failure`);
218
+ if (domPath) {
219
+ currentStep.domPath = domPath;
220
+ }
221
+ }
222
+ steps.push(currentStep);
223
+ continue;
224
+ }
225
+
226
+ // eslint-disable-next-line no-unused-vars
227
+ lastError = err;
228
+
229
+ // Capture screenshot and DOM on failure
230
+ if (artifactsDir) {
231
+ const screenshotPath = await this._captureScreenshot(
232
+ page,
233
+ artifactsDir,
234
+ `${stepDef.id}_failure`
235
+ );
236
+ if (screenshotPath) {
237
+ currentStep.screenshots.push(screenshotPath);
238
+ }
239
+ const domPath = await this._savePageContent(page, artifactsDir, `${stepDef.id}_failure`);
240
+ if (domPath) {
241
+ currentStep.domPath = domPath;
242
+ }
243
+ }
244
+
245
+ throw err; // Stop attempt on step failure
246
+ }
247
+
248
+ steps.push(currentStep);
249
+ }
250
+
251
+ // All steps successful, now check success conditions
252
+ const endedAt = new Date();
253
+ const totalDurationMs = endedAt.getTime() - startedAt.getTime();
254
+
255
+ // Check success conditions
256
+ let successMet = false;
257
+ let successReason = null;
258
+
259
+ for (const condition of attemptDef.successConditions) {
260
+ try {
261
+ if (condition.type === 'url') {
262
+ const currentUrl = page.url();
263
+ if (condition.pattern.test(currentUrl)) {
264
+ successMet = true;
265
+ successReason = `URL matched: ${currentUrl}`;
266
+ break;
267
+ }
268
+ } else if (condition.type === 'selector') {
269
+ // Wait briefly for selector to become visible
270
+ try {
271
+ await page.waitForSelector(condition.target, { timeout: 3000, state: 'visible' });
272
+ successMet = true;
273
+ successReason = `Success element visible: ${condition.target}`;
274
+ break;
275
+ } catch {
276
+ // Continue to next condition
277
+ }
278
+ }
279
+ } catch {
280
+ // Continue to next condition
281
+ }
282
+ }
283
+
284
+ if (!successMet) {
285
+ page.removeListener('console', consoleHandler);
286
+ page.removeListener('pageerror', pageErrorHandler);
287
+ return {
288
+ outcome: /** @type {AttemptOutcome} */ ('FAILURE'),
289
+ steps,
290
+ startedAt: startedAt.toISOString(),
291
+ endedAt: endedAt.toISOString(),
292
+ totalDurationMs,
293
+ friction: {
294
+ isFriction: false,
295
+ signals: [],
296
+ summary: null,
297
+ reasons: [],
298
+ thresholds: this.frictionThresholds,
299
+ metrics: {
300
+ totalDurationMs,
301
+ stepCount: steps.length,
302
+ totalRetries: steps.reduce((sum, s) => sum + s.retries, 0),
303
+ maxStepDurationMs: Math.max(...steps.map(s => s.durationMs || 0), 0)
304
+ }
305
+ },
306
+ error: 'Success conditions not met after all steps completed',
307
+ successReason: null,
308
+ validators: [],
309
+ softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
310
+ discoverySignals: {
311
+ consoleErrorCount: consoleErrors.length,
312
+ pageErrorCount: pageErrors.length
313
+ }
314
+ };
315
+ }
316
+
317
+ // Run validators for soft failure detection (Phase 2)
318
+ let validatorResults = [];
319
+ let softFailureAnalysis = { hasSoftFailure: false, failureCount: 0, warnCount: 0 };
320
+
321
+ if (validatorSpecs && validatorSpecs.length > 0) {
322
+ const validatorContext = {
323
+ page,
324
+ consoleMessages,
325
+ url: page.url()
326
+ };
327
+
328
+ validatorResults = await runValidators(validatorSpecs, validatorContext);
329
+ softFailureAnalysis = analyzeSoftFailures(validatorResults);
330
+
331
+ // If validators detected soft failures, upgrade outcome
332
+ if (softFailureAnalysis.hasSoftFailure) {
333
+ // Soft failure still counts as FAILURE (outcome), not FRICTION
334
+ // Soft failures are recorded separately for analysis
335
+ }
336
+ }
337
+
338
+ // Check for friction signals in total duration
339
+ if (totalDurationMs > this.frictionThresholds.totalDurationMs) {
340
+ frictionSignals.push({
341
+ id: 'slow_total_duration',
342
+ description: `Total attempt duration exceeded threshold`,
343
+ metric: 'totalDurationMs',
344
+ threshold: this.frictionThresholds.totalDurationMs,
345
+ observedValue: totalDurationMs,
346
+ affectedStepId: null,
347
+ severity: /** @type {FrictionSeverity} */ ('low')
348
+ });
349
+ frictionReasons.push(`Attempt took ${totalDurationMs}ms total (threshold: ${this.frictionThresholds.totalDurationMs}ms)`);
350
+ }
351
+
352
+ frictionMetrics = {
353
+ totalDurationMs,
354
+ stepCount: steps.length,
355
+ totalRetries: steps.reduce((sum, s) => sum + s.retries, 0),
356
+ maxStepDurationMs: Math.max(...steps.map(s => s.durationMs || 0))
357
+ };
358
+
359
+ // Determine outcome based on friction signals
360
+ const isFriction = frictionSignals.length > 0;
361
+ const outcome = /** @type {AttemptOutcome} */ (isFriction ? 'FRICTION' : 'SUCCESS');
362
+
363
+ // Generate friction summary
364
+ const frictionSummary = isFriction
365
+ ? `User succeeded, but encountered ${frictionSignals.length} friction ${frictionSignals.length === 1 ? 'signal' : 'signals'}`
366
+ : null;
367
+
368
+ return {
369
+ outcome,
370
+ steps,
371
+ startedAt: startedAt.toISOString(),
372
+ endedAt: endedAt.toISOString(),
373
+ totalDurationMs,
374
+ friction: {
375
+ isFriction,
376
+ signals: frictionSignals,
377
+ summary: frictionSummary,
378
+ reasons: frictionReasons, // Keep for backward compatibility
379
+ thresholds: this.frictionThresholds,
380
+ metrics: frictionMetrics
381
+ },
382
+ error: null,
383
+ successReason,
384
+ validators: validatorResults,
385
+ softFailures: softFailureAnalysis,
386
+ discoverySignals: {
387
+ consoleErrorCount: consoleErrors.length,
388
+ pageErrorCount: pageErrors.length
389
+ }
390
+ };
391
+
392
+ } catch (err) {
393
+ const endedAt = new Date();
394
+ page.removeListener('console', consoleHandler);
395
+ page.removeListener('pageerror', pageErrorHandler);
396
+ const failureDurationMs = endedAt.getTime() - startedAt.getTime();
397
+ return {
398
+ outcome: /** @type {AttemptOutcome} */ ('FAILURE'),
399
+ steps,
400
+ startedAt: startedAt.toISOString(),
401
+ endedAt: endedAt.toISOString(),
402
+ totalDurationMs: failureDurationMs,
403
+ friction: {
404
+ isFriction: false,
405
+ signals: [],
406
+ summary: null,
407
+ reasons: [],
408
+ thresholds: this.frictionThresholds,
409
+ metrics: {
410
+ totalDurationMs: failureDurationMs,
411
+ stepCount: steps.length,
412
+ totalRetries: steps.reduce((sum, s) => sum + s.retries, 0),
413
+ maxStepDurationMs: Math.max(...steps.map(s => s.durationMs || 0), 0)
414
+ }
415
+ },
416
+ error: `Step "${currentStep?.id}" failed: ${err.message}`,
417
+ successReason: null,
418
+ validators: [],
419
+ softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
420
+ discoverySignals: {
421
+ consoleErrorCount: consoleErrors.length,
422
+ pageErrorCount: pageErrors.length
423
+ }
424
+ };
425
+ } finally {
426
+ page.removeListener('console', consoleHandler);
427
+ page.removeListener('pageerror', pageErrorHandler);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Execute a single step
433
+ */
434
+ async _executeStep(page, stepDef) {
435
+ const timeout = stepDef.timeout || this.timeout;
436
+
437
+ switch (stepDef.type) {
438
+ case 'navigate':
439
+ await page.goto(stepDef.target, {
440
+ waitUntil: 'domcontentloaded',
441
+ timeout
442
+ });
443
+ break;
444
+
445
+ case 'click':
446
+ // Try each selector in the target (semicolon-separated)
447
+ const selectors = stepDef.target.split(',').map(s => s.trim());
448
+ let clicked = false;
449
+
450
+ for (const selector of selectors) {
451
+ try {
452
+ await page.click(selector, { timeout: 5000 });
453
+ clicked = true;
454
+ break;
455
+ } catch {
456
+ // Try next selector
457
+ }
458
+ }
459
+
460
+ if (!clicked) {
461
+ throw new Error(`Could not click element: ${stepDef.target}`);
462
+ }
463
+
464
+ // Wait for navigation if expected
465
+ if (stepDef.waitForNavigation) {
466
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
467
+ }
468
+ break;
469
+
470
+ case 'type':
471
+ // Try each selector
472
+ const typeSelectors = stepDef.target.split(',').map(s => s.trim());
473
+ let typed = false;
474
+
475
+ for (const selector of typeSelectors) {
476
+ try {
477
+ await page.fill(selector, stepDef.value, { timeout: 5000 });
478
+ typed = true;
479
+ break;
480
+ } catch {
481
+ // Try next selector
482
+ }
483
+ }
484
+
485
+ if (!typed) {
486
+ throw new Error(`Could not type into element: ${stepDef.target}`);
487
+ }
488
+ break;
489
+
490
+ case 'waitFor':
491
+ const waitSelectors = stepDef.target.split(',').map(s => s.trim());
492
+ let found = false;
493
+ let earlyExitReason = null;
494
+
495
+ for (const selector of waitSelectors) {
496
+ try {
497
+ // Phase 7.4: Adaptive timeout
498
+ const adaptiveTimeout = stepDef.timeout || 5000;
499
+
500
+ await page.waitForSelector(selector, {
501
+ timeout: adaptiveTimeout,
502
+ state: stepDef.state || 'visible'
503
+ });
504
+ found = true;
505
+ break;
506
+ } catch (err) {
507
+ // Phase 7.4: Detect early exit signals
508
+ if (err.message && err.message.includes('Timeout')) {
509
+ earlyExitReason = 'Target never appeared (DOM settled)';
510
+ }
511
+ }
512
+ }
513
+
514
+ if (!found) {
515
+ // Phase 7.4: Include early exit reason
516
+ const errorMsg = earlyExitReason
517
+ ? `${earlyExitReason}: ${stepDef.target}`
518
+ : `Element not found: ${stepDef.target}`;
519
+ throw new Error(errorMsg);
520
+ }
521
+ break;
522
+
523
+ case 'wait':
524
+ await page.waitForTimeout(stepDef.duration || 1000);
525
+ break;
526
+
527
+ default:
528
+ throw new Error(`Unknown step type: ${stepDef.type}`);
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Capture screenshot
534
+ */
535
+ async _captureScreenshot(page, artifactsDir, stepId) {
536
+ try {
537
+ const screenshotsDir = path.join(artifactsDir, 'attempt-screenshots');
538
+ if (!fs.existsSync(screenshotsDir)) {
539
+ fs.mkdirSync(screenshotsDir, { recursive: true });
540
+ }
541
+
542
+ const filename = `${stepId}.jpeg`;
543
+ const fullPath = path.join(screenshotsDir, filename);
544
+
545
+ await page.screenshot({
546
+ path: fullPath,
547
+ type: 'jpeg',
548
+ quality: 80,
549
+ fullPage: true
550
+ });
551
+
552
+ return filename;
553
+ } catch {
554
+ return null;
555
+ }
556
+ }
557
+
558
+ async _savePageContent(page, artifactsDir, stepId) {
559
+ try {
560
+ const domDir = path.join(artifactsDir, 'attempt-dom');
561
+ if (!fs.existsSync(domDir)) {
562
+ fs.mkdirSync(domDir, { recursive: true });
563
+ }
564
+ const filename = `${stepId}.html`;
565
+ const fullPath = path.join(domDir, filename);
566
+ const content = await page.content();
567
+ fs.writeFileSync(fullPath, content, 'utf-8');
568
+ return path.relative(artifactsDir, fullPath);
569
+ } catch {
570
+ return null;
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Check if an attempt is applicable to this site
576
+ * Returns: { applicable: boolean, confidence: number, reason: string, discoverySignals: {} }
577
+ */
578
+ async checkAttemptApplicability(page, attemptId) {
579
+ const attemptDef = this.loadAttemptDefinition(attemptId);
580
+ if (!attemptDef) {
581
+ return {
582
+ applicable: false,
583
+ confidence: 0,
584
+ reason: 'Attempt not found in registry',
585
+ discoverySignals: {}
586
+ };
587
+ }
588
+
589
+ // Map attempt IDs to feature types
590
+ const featureTypeMap = {
591
+ 'login': 'login',
592
+ 'signup': 'signup',
593
+ 'checkout': 'checkout',
594
+ 'contact_form': 'contact_form',
595
+ 'newsletter_signup': 'newsletter',
596
+ 'language_switch': 'language_switch'
597
+ };
598
+
599
+ const featureType = featureTypeMap[attemptId] || null;
600
+
601
+ if (!featureType) {
602
+ // Attempt with no feature detection (e.g., custom attempts) - always applicable
603
+ return {
604
+ applicable: true,
605
+ confidence: 0.5,
606
+ reason: 'Custom attempt, assuming applicable',
607
+ discoverySignals: {}
608
+ };
609
+ }
610
+
611
+ try {
612
+ const detection = await detectFeature(page, featureType);
613
+ return {
614
+ applicable: detection.present,
615
+ confidence: detection.confidence,
616
+ reason: detection.present
617
+ ? `Feature detected with signals: ${detection.evidence.join(', ')}`
618
+ : `Feature not detected; no signals found`,
619
+ discoverySignals: {
620
+ featureType,
621
+ detectionSignals: detection.evidence,
622
+ detected: detection.present,
623
+ confidence: detection.confidence
624
+ }
625
+ };
626
+ } catch (err) {
627
+ return {
628
+ applicable: false,
629
+ confidence: 0,
630
+ reason: `Detection error: ${err.message}`,
631
+ discoverySignals: { error: err.message }
632
+ };
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Attempt to find an element using fallback selectors
638
+ * Used by _executeStep when element not found with primary selector
639
+ * Returns: { element, discoverySignals }
640
+ */
641
+ async findElementWithFallbacks(page, goalType) {
642
+ try {
643
+ const selectorChain = buildSelectorChain(goalType);
644
+ if (!selectorChain || selectorChain.length === 0) {
645
+ return {
646
+ element: null,
647
+ discoverySignals: { error: `No selector chain for goal: ${goalType}` }
648
+ };
649
+ }
650
+
651
+ const result = await findElement(page, selectorChain, { timeout: 5000, requireVisible: true });
652
+ return {
653
+ element: result.element,
654
+ discoverySignals: {
655
+ goalType,
656
+ selectorChainLength: selectorChain.length,
657
+ strategy: result.strategy,
658
+ confidence: result.confidence,
659
+ found: result.element ? true : false,
660
+ ...result.discoverySignals
661
+ }
662
+ };
663
+ } catch (err) {
664
+ return {
665
+ element: null,
666
+ discoverySignals: { error: err.message }
667
+ };
668
+ }
669
+ }
670
+
671
+ async _runSiteSmokeAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors) {
672
+ const startedAt = new Date();
673
+ const steps = [];
674
+ const discoverySignals = {
675
+ discoveredLinks: [],
676
+ chosenTargets: [],
677
+ navigationResults: [],
678
+ consoleErrorCount: consoleErrors.length,
679
+ pageErrorCount: pageErrors.length
680
+ };
681
+
682
+ const recordStep = (step) => {
683
+ steps.push(step);
684
+ };
685
+
686
+ // Step: navigate home
687
+ let homepageStatus = null;
688
+ try {
689
+ const resp = await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: this.timeout });
690
+ homepageStatus = resp ? resp.status() : null;
691
+ recordStep({
692
+ id: 'navigate_home',
693
+ type: 'navigate',
694
+ target: baseUrl,
695
+ status: /** @type {StepStatus} */ ('success'),
696
+ startedAt: startedAt.toISOString(),
697
+ endedAt: new Date().toISOString(),
698
+ durationMs: null,
699
+ retries: 0,
700
+ screenshots: []
701
+ });
702
+ if (artifactsDir) {
703
+ await this._captureScreenshot(page, artifactsDir, 'site_smoke_home');
704
+ }
705
+ } catch (err) {
706
+ recordStep({
707
+ id: 'navigate_home',
708
+ type: 'navigate',
709
+ target: baseUrl,
710
+ status: /** @type {StepStatus} */ ('failed'),
711
+ error: err.message,
712
+ startedAt: startedAt.toISOString(),
713
+ endedAt: new Date().toISOString(),
714
+ durationMs: null,
715
+ retries: 0,
716
+ screenshots: []
717
+ });
718
+ const failureEndedAt = new Date();
719
+ const failureDurationMs = failureEndedAt.getTime() - startedAt.getTime();
720
+ return {
721
+ outcome: /** @type {AttemptOutcome} */ ('FAILURE'),
722
+ steps,
723
+ startedAt: startedAt.toISOString(),
724
+ endedAt: failureEndedAt.toISOString(),
725
+ totalDurationMs: failureDurationMs,
726
+ friction: {
727
+ isFriction: false,
728
+ signals: [],
729
+ summary: null,
730
+ reasons: [],
731
+ thresholds: this.frictionThresholds,
732
+ metrics: {
733
+ totalDurationMs: failureDurationMs,
734
+ stepCount: steps.length,
735
+ totalRetries: steps.reduce((sum, s) => sum + (s.retries || 0), 0),
736
+ maxStepDurationMs: Math.max(...steps.map(s => s.durationMs || 0), 0)
737
+ }
738
+ },
739
+ error: `Failed to load homepage: ${err.message}`,
740
+ successReason: null,
741
+ validators: [],
742
+ softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
743
+ discoverySignals
744
+ };
745
+ }
746
+
747
+ // Discover internal links from header/nav/footer
748
+ const prioritized = ['docs', 'pricing', 'features', 'about', 'contact', 'login', 'signup', 'privacy', 'terms'];
749
+ const baseOrigin = new URL(baseUrl).origin;
750
+ const { discoveredLinks, chosenLinks } = await page.evaluate(({ origin, prioritizedList }) => {
751
+ const anchors = Array.from(document.querySelectorAll('header a[href], nav a[href], footer a[href], a[href]'));
752
+ const cleaned = anchors
753
+ .map(a => ({ href: a.getAttribute('href') || '', text: (a.textContent || '').trim() }))
754
+ .filter(a => a.href && !a.href.startsWith('mailto:') && !a.href.startsWith('tel:') && !a.href.startsWith('javascript:'))
755
+ .map(a => {
756
+ let abs = a.href;
757
+ try {
758
+ abs = new URL(a.href, origin).href;
759
+ } catch (_) {}
760
+ return { ...a, abs };
761
+ })
762
+ .filter(a => a.abs.startsWith(origin));
763
+
764
+ const seen = new Set();
765
+ const unique = [];
766
+ for (const link of cleaned) {
767
+ if (seen.has(link.abs)) continue;
768
+ seen.add(link.abs);
769
+ unique.push(link);
770
+ }
771
+
772
+ const prioritizedMatches = [];
773
+ for (const link of unique) {
774
+ const lower = (link.abs + ' ' + link.text).toLowerCase();
775
+ const match = prioritizedList.find(p => lower.includes(`/${p}`) || lower.includes(p));
776
+ if (match) {
777
+ prioritizedMatches.push({ ...link, priority: prioritizedList.indexOf(match) });
778
+ }
779
+ }
780
+
781
+ prioritizedMatches.sort((a, b) => a.priority - b.priority);
782
+ const topPrioritized = prioritizedMatches.slice(0, 3);
783
+ const fallback = unique.filter(l => !topPrioritized.find(t => t.abs === l.abs)).slice(0, 3 - topPrioritized.length);
784
+ const chosen = [...topPrioritized, ...fallback];
785
+
786
+ return {
787
+ discoveredLinks: unique,
788
+ chosenLinks: chosen
789
+ };
790
+ }, { origin: baseOrigin, prioritizedList: prioritized });
791
+
792
+ discoverySignals.discoveredLinks = discoveredLinks;
793
+ discoverySignals.chosenTargets = chosenLinks;
794
+
795
+ // Attempt navigation to chosen links (up to 3)
796
+ for (const link of chosenLinks) {
797
+ const start = Date.now();
798
+ const navResult = { target: link.abs, text: link.text, ok: false, status: null, finalUrl: null };
799
+ try {
800
+ const resp = await page.goto(link.abs, { waitUntil: 'domcontentloaded', timeout: this.timeout });
801
+ navResult.status = resp ? resp.status() : null;
802
+ navResult.finalUrl = page.url();
803
+ navResult.ok = (navResult.status && navResult.status < 400) || navResult.finalUrl.startsWith(link.abs);
804
+ } catch (err) {
805
+ navResult.error = err.message;
806
+ }
807
+ navResult.durationMs = Date.now() - start;
808
+ discoverySignals.navigationResults.push(navResult);
809
+ recordStep({
810
+ id: `nav_${link.text || link.abs}`,
811
+ type: 'navigate',
812
+ target: link.abs,
813
+ status: navResult.ok ? 'success' : 'failed',
814
+ error: navResult.ok ? null : navResult.error || 'Navigation failed',
815
+ startedAt: new Date(start).toISOString(),
816
+ endedAt: new Date().toISOString(),
817
+ durationMs: navResult.durationMs,
818
+ retries: 0,
819
+ screenshots: []
820
+ });
821
+ }
822
+
823
+ const executedOk = discoverySignals.navigationResults.some(r => r.ok) || homepageStatus !== null;
824
+ const endedAt = new Date();
825
+ const totalDurationMs = endedAt - startedAt;
826
+ const outcome = executedOk ? 'SUCCESS' : 'FAILURE';
827
+
828
+ return {
829
+ outcome,
830
+ steps,
831
+ startedAt: startedAt.toISOString(),
832
+ endedAt: endedAt.toISOString(),
833
+ totalDurationMs,
834
+ friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
835
+ error: executedOk ? null : 'No internal navigation succeeded',
836
+ successReason: executedOk ? 'At least one navigation completed' : null,
837
+ validators: [],
838
+ softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
839
+ discoverySignals: {
840
+ ...discoverySignals,
841
+ consoleErrorCount: consoleErrors.length,
842
+ pageErrorCount: pageErrors.length,
843
+ homepageStatus
844
+ }
845
+ };
846
+ }
847
+
848
+ async _runPrimaryCtasAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors) {
849
+ const startedAt = new Date();
850
+ const steps = [];
851
+ const selectorChainTried = ['text:Docs', 'text:Pricing', 'text:GitHub', 'text:Contact', 'text:Sign in', 'text:Sign up', 'text:Get started', 'text:Try', 'text:Demo'];
852
+ const discoverySignals = {
853
+ ctaCandidates: [],
854
+ navigationResults: [],
855
+ githubValidated: false,
856
+ selectorChainTried,
857
+ consoleErrorCount: consoleErrors.length,
858
+ pageErrorCount: pageErrors.length
859
+ };
860
+
861
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: this.timeout });
862
+ steps.push({ id: 'navigate_home', type: 'navigate', target: baseUrl, status: 'success', startedAt: startedAt.toISOString(), endedAt: new Date().toISOString(), retries: 0, screenshots: [] });
863
+ if (artifactsDir) {
864
+ await this._captureScreenshot(page, artifactsDir, 'primary_ctas_home');
865
+ }
866
+
867
+ const baseOrigin = new URL(baseUrl).origin;
868
+ const ctaCandidates = await page.evaluate(({ origin }) => {
869
+ const keywords = ['docs','pricing','github','contact','sign in','sign up','get started','try','demo','start'];
870
+ const elements = Array.from(document.querySelectorAll('a[href], button'));
871
+ const candidates = [];
872
+ for (const el of elements) {
873
+ const text = (el.textContent || '').trim();
874
+ if (!text) continue;
875
+ const lower = text.toLowerCase();
876
+ if (!keywords.some(k => lower.includes(k))) continue;
877
+ const href = el.getAttribute('href') || '';
878
+ let abs = href;
879
+ if (href) {
880
+ try {
881
+ abs = new URL(href, origin).href;
882
+ } catch (_) {}
883
+ }
884
+ candidates.push({ text, href, abs, tag: el.tagName, target: el.getAttribute('target') || null });
885
+ }
886
+ const seen = new Set();
887
+ const unique = [];
888
+ for (const c of candidates) {
889
+ const key = c.abs || c.text;
890
+ if (seen.has(key)) continue;
891
+ seen.add(key);
892
+ unique.push(c);
893
+ }
894
+ return unique;
895
+ }, { origin: baseOrigin });
896
+
897
+ discoverySignals.ctaCandidates = ctaCandidates;
898
+
899
+ if (ctaCandidates.length === 0) {
900
+ return {
901
+ outcome: /** @type {AttemptOutcome} */ ('NOT_APPLICABLE'),
902
+ skipReason: 'No CTA elements detected',
903
+ steps,
904
+ startedAt: startedAt.toISOString(),
905
+ endedAt: new Date().toISOString(),
906
+ totalDurationMs: Date.now() - startedAt.getTime(),
907
+ friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
908
+ error: null,
909
+ successReason: null,
910
+ validators: [],
911
+ softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
912
+ discoverySignals
913
+ };
914
+ }
915
+
916
+ const targets = ctaCandidates.slice(0, 2);
917
+ for (const target of targets) {
918
+ const start = Date.now();
919
+ const navResult = { target: target.abs || target.href, text: target.text, ok: false, status: null, finalUrl: null };
920
+ try {
921
+ const resp = await page.goto(target.abs || target.href || baseUrl, { waitUntil: 'domcontentloaded', timeout: this.timeout });
922
+ navResult.status = resp ? resp.status() : null;
923
+ navResult.finalUrl = page.url();
924
+ navResult.ok = (navResult.status && navResult.status < 400) || (navResult.finalUrl && navResult.finalUrl !== baseUrl);
925
+ if ((target.abs || '').includes('github.com') && navResult.ok) {
926
+ discoverySignals.githubValidated = true;
927
+ }
928
+ } catch (err) {
929
+ navResult.error = err.message;
930
+ }
931
+ navResult.durationMs = Date.now() - start;
932
+ discoverySignals.navigationResults.push(navResult);
933
+ steps.push({
934
+ id: `cta_${target.text.toLowerCase().replace(/\s+/g, '_')}`,
935
+ type: 'navigate',
936
+ target: target.abs || target.href,
937
+ status: navResult.ok ? 'success' : 'failed',
938
+ error: navResult.ok ? null : navResult.error || 'Navigation failed',
939
+ startedAt: new Date(start).toISOString(),
940
+ endedAt: new Date().toISOString(),
941
+ retries: 0,
942
+ screenshots: []
943
+ });
944
+ }
945
+
946
+ const executedOk = discoverySignals.navigationResults.some(r => r.ok);
947
+ const endedAt = new Date();
948
+ const totalDurationMs = endedAt - startedAt;
949
+ return {
950
+ outcome: executedOk ? 'SUCCESS' : 'FAILURE',
951
+ steps,
952
+ startedAt: startedAt.toISOString(),
953
+ endedAt: endedAt.toISOString(),
954
+ totalDurationMs,
955
+ friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
956
+ error: executedOk ? null : 'CTA navigation did not succeed',
957
+ successReason: executedOk ? 'CTA navigation completed' : null,
958
+ validators: [],
959
+ softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
960
+ discoverySignals
961
+ };
962
+ }
963
+
964
+ async _runContactDiscoveryAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors) {
965
+ const startedAt = new Date();
966
+ const steps = [];
967
+ const discoverySignals = {
968
+ mailto: null,
969
+ contactLinks: [],
970
+ navigationResults: [],
971
+ consoleErrorCount: consoleErrors.length,
972
+ pageErrorCount: pageErrors.length
973
+ };
974
+
975
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: this.timeout });
976
+ steps.push({ id: 'navigate_home', type: 'navigate', target: baseUrl, status: 'success', startedAt: startedAt.toISOString(), endedAt: new Date().toISOString(), retries: 0, screenshots: [] });
977
+
978
+ const baseOrigin = new URL(baseUrl).origin;
979
+ const contactInfo = await page.evaluate(({ origin }) => {
980
+ const anchors = Array.from(document.querySelectorAll('a[href]'));
981
+ const mailto = anchors.find(a => (a.getAttribute('href') || '').startsWith('mailto:'));
982
+ if (mailto) {
983
+ return { mailto: mailto.getAttribute('href'), contactLinks: [] };
984
+ }
985
+ const contactLinks = anchors
986
+ .filter(a => {
987
+ const href = a.getAttribute('href') || '';
988
+ const text = (a.textContent || '').toLowerCase();
989
+ return href.toLowerCase().includes('contact') || text.includes('contact');
990
+ })
991
+ .map(a => {
992
+ const href = a.getAttribute('href') || '';
993
+ let abs = href;
994
+ try { abs = new URL(href, origin).href; } catch (_) {}
995
+ return { href, abs, text: (a.textContent || '').trim() };
996
+ });
997
+ return { mailto: null, contactLinks };
998
+ }, { origin: baseOrigin });
999
+
1000
+ discoverySignals.mailto = contactInfo.mailto;
1001
+ discoverySignals.contactLinks = contactInfo.contactLinks;
1002
+
1003
+ if (contactInfo.mailto) {
1004
+ return {
1005
+ outcome: /** @type {AttemptOutcome} */ ('SUCCESS'),
1006
+ steps,
1007
+ startedAt: startedAt.toISOString(),
1008
+ endedAt: new Date().toISOString(),
1009
+ totalDurationMs: Date.now() - startedAt.getTime(),
1010
+ friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
1011
+ error: null,
1012
+ successReason: `Found mailto: ${contactInfo.mailto}`,
1013
+ validators: [],
1014
+ softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
1015
+ discoverySignals
1016
+ };
1017
+ }
1018
+
1019
+ if (contactInfo.contactLinks.length === 0) {
1020
+ return {
1021
+ outcome: /** @type {AttemptOutcome} */ ('NOT_APPLICABLE'),
1022
+ skipReason: 'No contact link or mailto detected',
1023
+ steps,
1024
+ startedAt: startedAt.toISOString(),
1025
+ endedAt: new Date().toISOString(),
1026
+ totalDurationMs: Date.now() - startedAt.getTime(),
1027
+ friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
1028
+ error: null,
1029
+ successReason: null,
1030
+ validators: [],
1031
+ softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
1032
+ discoverySignals
1033
+ };
1034
+ }
1035
+
1036
+ const target = contactInfo.contactLinks[0];
1037
+ const startNav = Date.now();
1038
+ const navResult = { target: target.abs || target.href, text: target.text, ok: false, status: null, finalUrl: null };
1039
+ try {
1040
+ const resp = await page.goto(target.abs || target.href, { waitUntil: 'domcontentloaded', timeout: this.timeout });
1041
+ navResult.status = resp ? resp.status() : null;
1042
+ navResult.finalUrl = page.url();
1043
+ navResult.ok = (navResult.status && navResult.status < 400) || (navResult.finalUrl && navResult.finalUrl.includes('contact'));
1044
+ } catch (err) {
1045
+ navResult.error = err.message;
1046
+ }
1047
+ navResult.durationMs = Date.now() - startNav;
1048
+ discoverySignals.navigationResults.push(navResult);
1049
+ 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: [] });
1050
+
1051
+ const endedAt = new Date();
1052
+ const totalDurationMs = endedAt - startedAt;
1053
+ return {
1054
+ outcome: navResult.ok ? 'SUCCESS' : 'FAILURE',
1055
+ steps,
1056
+ startedAt: startedAt.toISOString(),
1057
+ endedAt: endedAt.toISOString(),
1058
+ totalDurationMs,
1059
+ friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
1060
+ error: navResult.ok ? null : 'Contact link navigation failed',
1061
+ successReason: navResult.ok ? 'Contact link reachable' : null,
1062
+ validators: [],
1063
+ softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
1064
+ discoverySignals
1065
+ };
1066
+ }
1067
+ }
1068
+
1069
+ module.exports = { AttemptEngine };