@odavl/guardian 0.1.0-rc1

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 (56) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -0
  4. package/bin/guardian.js +690 -0
  5. package/flows/example-login-flow.json +36 -0
  6. package/flows/example-signup-flow.json +44 -0
  7. package/guardian-contract-v1.md +149 -0
  8. package/guardian.config.json +54 -0
  9. package/guardian.policy.json +12 -0
  10. package/guardian.profile.docs.yaml +18 -0
  11. package/guardian.profile.ecommerce.yaml +17 -0
  12. package/guardian.profile.marketing.yaml +18 -0
  13. package/guardian.profile.saas.yaml +21 -0
  14. package/package.json +69 -0
  15. package/policies/enterprise.json +12 -0
  16. package/policies/saas.json +12 -0
  17. package/policies/startup.json +12 -0
  18. package/src/guardian/attempt-engine.js +454 -0
  19. package/src/guardian/attempt-registry.js +227 -0
  20. package/src/guardian/attempt-reporter.js +507 -0
  21. package/src/guardian/attempt.js +227 -0
  22. package/src/guardian/auto-attempt-builder.js +283 -0
  23. package/src/guardian/baseline-reporter.js +143 -0
  24. package/src/guardian/baseline-storage.js +285 -0
  25. package/src/guardian/baseline.js +492 -0
  26. package/src/guardian/behavioral-signals.js +261 -0
  27. package/src/guardian/breakage-intelligence.js +223 -0
  28. package/src/guardian/browser.js +92 -0
  29. package/src/guardian/cli-summary.js +141 -0
  30. package/src/guardian/crawler.js +142 -0
  31. package/src/guardian/discovery-engine.js +661 -0
  32. package/src/guardian/enhanced-html-reporter.js +305 -0
  33. package/src/guardian/failure-taxonomy.js +169 -0
  34. package/src/guardian/flow-executor.js +374 -0
  35. package/src/guardian/flow-registry.js +67 -0
  36. package/src/guardian/html-reporter.js +414 -0
  37. package/src/guardian/index.js +218 -0
  38. package/src/guardian/init-command.js +139 -0
  39. package/src/guardian/junit-reporter.js +264 -0
  40. package/src/guardian/market-criticality.js +335 -0
  41. package/src/guardian/market-reporter.js +305 -0
  42. package/src/guardian/network-trace.js +178 -0
  43. package/src/guardian/policy.js +357 -0
  44. package/src/guardian/preset-loader.js +148 -0
  45. package/src/guardian/reality.js +547 -0
  46. package/src/guardian/reporter.js +181 -0
  47. package/src/guardian/root-cause-analysis.js +171 -0
  48. package/src/guardian/safety.js +248 -0
  49. package/src/guardian/scan-presets.js +60 -0
  50. package/src/guardian/screenshot.js +152 -0
  51. package/src/guardian/sitemap.js +225 -0
  52. package/src/guardian/snapshot-schema.js +266 -0
  53. package/src/guardian/snapshot.js +327 -0
  54. package/src/guardian/validators.js +323 -0
  55. package/src/guardian/visual-diff.js +247 -0
  56. package/src/guardian/webhook.js +206 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Guardian Attempt Engine - PHASE 1 + PHASE 2
3
+ * Executes a single user attempt and tracks outcome (SUCCESS, FAILURE, FRICTION)
4
+ * Phase 2: Soft failure detection via validators
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { getAttemptDefinition } = require('./attempt-registry');
10
+ const { runValidators, analyzeSoftFailures } = require('./validators');
11
+
12
+ class AttemptEngine {
13
+ constructor(options = {}) {
14
+ this.attemptId = options.attemptId || 'default';
15
+ this.timeout = options.timeout || 30000;
16
+ this.frictionThresholds = options.frictionThresholds || {
17
+ totalDurationMs: 2500, // Total attempt > 2.5s
18
+ stepDurationMs: 1500, // Any single step > 1.5s
19
+ retryCount: 1 // More than 1 retry = friction
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Load attempt definition by ID (Phase 3 registry)
25
+ */
26
+ loadAttemptDefinition(attemptId) {
27
+ return getAttemptDefinition(attemptId);
28
+ }
29
+
30
+ /**
31
+ * Execute a single attempt
32
+ * Returns: { outcome, steps, timings, friction, error, validators, softFailures }
33
+ */
34
+ async executeAttempt(page, attemptId, baseUrl, artifactsDir = null, validatorSpecs = null) {
35
+ const attemptDef = this.loadAttemptDefinition(attemptId);
36
+ if (!attemptDef) {
37
+ throw new Error(`Attempt ${attemptId} not found`);
38
+ }
39
+
40
+ const startedAt = new Date();
41
+ const steps = [];
42
+ const frictionSignals = [];
43
+ const consoleMessages = []; // Capture console messages for validators
44
+ let currentStep = null;
45
+ let lastError = null;
46
+ let frictionReasons = [];
47
+ let frictionMetrics = {};
48
+
49
+ // Capture console messages for soft failure detection
50
+ const consoleHandler = (msg) => {
51
+ consoleMessages.push({
52
+ type: msg.type(), // 'log', 'error', 'warning', etc.
53
+ text: msg.text(),
54
+ location: msg.location()
55
+ });
56
+ };
57
+
58
+ page.on('console', consoleHandler);
59
+
60
+ try {
61
+ // Replace $BASEURL placeholder in all steps
62
+ const processedSteps = attemptDef.baseSteps.map(step => {
63
+ if (step.target && step.target === '$BASEURL') {
64
+ return { ...step, target: baseUrl };
65
+ }
66
+ return step;
67
+ });
68
+
69
+ // Execute each step
70
+ for (const stepDef of processedSteps) {
71
+ currentStep = {
72
+ id: stepDef.id,
73
+ type: stepDef.type,
74
+ target: stepDef.target,
75
+ description: stepDef.description,
76
+ startedAt: new Date().toISOString(),
77
+ retries: 0,
78
+ status: 'pending',
79
+ error: null,
80
+ screenshots: []
81
+ };
82
+
83
+ const stepStartTime = Date.now();
84
+
85
+ try {
86
+ // Execute with retry logic (up to 2 attempts)
87
+ let success = false;
88
+ for (let attempt = 0; attempt < 2; attempt++) {
89
+ try {
90
+ if (attempt > 0) {
91
+ currentStep.retries++;
92
+ // Small backoff before retry
93
+ await page.waitForTimeout(200);
94
+ }
95
+
96
+ await this._executeStep(page, stepDef);
97
+ success = true;
98
+ break;
99
+ } catch (err) {
100
+ if (attempt === 1) {
101
+ throw err; // Final attempt failed
102
+ }
103
+ // Retry on first failure
104
+ }
105
+ }
106
+
107
+ const stepEndTime = Date.now();
108
+ const stepDurationMs = stepEndTime - stepStartTime;
109
+
110
+ currentStep.endedAt = new Date().toISOString();
111
+ currentStep.durationMs = stepDurationMs;
112
+ currentStep.status = 'success';
113
+
114
+ // Check for friction signals in step timing
115
+ if (stepDurationMs > this.frictionThresholds.stepDurationMs) {
116
+ frictionSignals.push({
117
+ id: 'slow_step_execution',
118
+ description: `Step took longer than threshold`,
119
+ metric: 'stepDurationMs',
120
+ threshold: this.frictionThresholds.stepDurationMs,
121
+ observedValue: stepDurationMs,
122
+ affectedStepId: stepDef.id,
123
+ severity: 'medium'
124
+ });
125
+ frictionReasons.push(`Step "${stepDef.id}" took ${stepDurationMs}ms (threshold: ${this.frictionThresholds.stepDurationMs}ms)`);
126
+ }
127
+
128
+ if (currentStep.retries > this.frictionThresholds.retryCount) {
129
+ frictionSignals.push({
130
+ id: 'multiple_retries_required',
131
+ description: `Step required multiple retry attempts`,
132
+ metric: 'retryCount',
133
+ threshold: this.frictionThresholds.retryCount,
134
+ observedValue: currentStep.retries,
135
+ affectedStepId: stepDef.id,
136
+ severity: 'high'
137
+ });
138
+ frictionReasons.push(`Step "${stepDef.id}" required ${currentStep.retries} retries`);
139
+ }
140
+
141
+ // Capture screenshot on success if artifacts dir provided
142
+ if (artifactsDir) {
143
+ const screenshotPath = await this._captureScreenshot(
144
+ page,
145
+ artifactsDir,
146
+ stepDef.id
147
+ );
148
+ if (screenshotPath) {
149
+ currentStep.screenshots.push(screenshotPath);
150
+ }
151
+ }
152
+
153
+ } catch (err) {
154
+ currentStep.endedAt = new Date().toISOString();
155
+ currentStep.durationMs = Date.now() - stepStartTime;
156
+ currentStep.status = stepDef.optional ? 'skipped' : 'failed';
157
+ currentStep.error = err.message;
158
+
159
+ if (stepDef.optional) {
160
+ // Optional steps should not fail the attempt; record and move on
161
+ steps.push(currentStep);
162
+ continue;
163
+ }
164
+
165
+ lastError = err;
166
+
167
+ // Capture screenshot on failure
168
+ if (artifactsDir) {
169
+ const screenshotPath = await this._captureScreenshot(
170
+ page,
171
+ artifactsDir,
172
+ `${stepDef.id}_failure`
173
+ );
174
+ if (screenshotPath) {
175
+ currentStep.screenshots.push(screenshotPath);
176
+ }
177
+ }
178
+
179
+ throw err; // Stop attempt on step failure
180
+ }
181
+
182
+ steps.push(currentStep);
183
+ }
184
+
185
+ // All steps successful, now check success conditions
186
+ const endedAt = new Date();
187
+ const totalDurationMs = endedAt.getTime() - startedAt.getTime();
188
+
189
+ // Check success conditions
190
+ let successMet = false;
191
+ let successReason = null;
192
+
193
+ for (const condition of attemptDef.successConditions) {
194
+ try {
195
+ if (condition.type === 'url') {
196
+ const currentUrl = page.url();
197
+ if (condition.pattern.test(currentUrl)) {
198
+ successMet = true;
199
+ successReason = `URL matched: ${currentUrl}`;
200
+ break;
201
+ }
202
+ } else if (condition.type === 'selector') {
203
+ // Wait briefly for selector to become visible
204
+ try {
205
+ await page.waitForSelector(condition.target, { timeout: 3000, state: 'visible' });
206
+ successMet = true;
207
+ successReason = `Success element visible: ${condition.target}`;
208
+ break;
209
+ } catch (e) {
210
+ // Continue to next condition
211
+ }
212
+ }
213
+ } catch (err) {
214
+ // Continue to next condition
215
+ }
216
+ }
217
+
218
+ if (!successMet) {
219
+ page.removeListener('console', consoleHandler);
220
+ return {
221
+ outcome: 'FAILURE',
222
+ steps,
223
+ startedAt: startedAt.toISOString(),
224
+ endedAt: endedAt.toISOString(),
225
+ totalDurationMs,
226
+ friction: {
227
+ isFriction: false,
228
+ signals: [],
229
+ summary: null,
230
+ reasons: [],
231
+ thresholds: this.frictionThresholds,
232
+ metrics: {}
233
+ },
234
+ error: 'Success conditions not met after all steps completed',
235
+ successReason: null,
236
+ validators: [],
237
+ softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 }
238
+ };
239
+ }
240
+
241
+ // Run validators for soft failure detection (Phase 2)
242
+ let validatorResults = [];
243
+ let softFailureAnalysis = { hasSoftFailure: false, failureCount: 0, warnCount: 0 };
244
+
245
+ if (validatorSpecs && validatorSpecs.length > 0) {
246
+ const validatorContext = {
247
+ page,
248
+ consoleMessages,
249
+ url: page.url()
250
+ };
251
+
252
+ validatorResults = await runValidators(validatorSpecs, validatorContext);
253
+ softFailureAnalysis = analyzeSoftFailures(validatorResults);
254
+
255
+ // If validators detected soft failures, upgrade outcome
256
+ if (softFailureAnalysis.hasSoftFailure) {
257
+ // Soft failure still counts as FAILURE (outcome), not FRICTION
258
+ // Soft failures are recorded separately for analysis
259
+ }
260
+ }
261
+
262
+ // Check for friction signals in total duration
263
+ if (totalDurationMs > this.frictionThresholds.totalDurationMs) {
264
+ frictionSignals.push({
265
+ id: 'slow_total_duration',
266
+ description: `Total attempt duration exceeded threshold`,
267
+ metric: 'totalDurationMs',
268
+ threshold: this.frictionThresholds.totalDurationMs,
269
+ observedValue: totalDurationMs,
270
+ affectedStepId: null,
271
+ severity: 'low'
272
+ });
273
+ frictionReasons.push(`Attempt took ${totalDurationMs}ms total (threshold: ${this.frictionThresholds.totalDurationMs}ms)`);
274
+ }
275
+
276
+ frictionMetrics = {
277
+ totalDurationMs,
278
+ stepCount: steps.length,
279
+ totalRetries: steps.reduce((sum, s) => sum + s.retries, 0),
280
+ maxStepDurationMs: Math.max(...steps.map(s => s.durationMs || 0))
281
+ };
282
+
283
+ // Determine outcome based on friction signals
284
+ const isFriction = frictionSignals.length > 0;
285
+ const outcome = isFriction ? 'FRICTION' : 'SUCCESS';
286
+
287
+ // Generate friction summary
288
+ const frictionSummary = isFriction
289
+ ? `User succeeded, but encountered ${frictionSignals.length} friction ${frictionSignals.length === 1 ? 'signal' : 'signals'}`
290
+ : null;
291
+
292
+ return {
293
+ outcome,
294
+ steps,
295
+ startedAt: startedAt.toISOString(),
296
+ endedAt: endedAt.toISOString(),
297
+ totalDurationMs,
298
+ friction: {
299
+ isFriction,
300
+ signals: frictionSignals,
301
+ summary: frictionSummary,
302
+ reasons: frictionReasons, // Keep for backward compatibility
303
+ thresholds: this.frictionThresholds,
304
+ metrics: frictionMetrics
305
+ },
306
+ error: null,
307
+ successReason,
308
+ validators: validatorResults,
309
+ softFailures: softFailureAnalysis
310
+ };
311
+
312
+ } catch (err) {
313
+ const endedAt = new Date();
314
+ page.removeListener('console', consoleHandler);
315
+ return {
316
+ outcome: 'FAILURE',
317
+ steps,
318
+ startedAt: startedAt.toISOString(),
319
+ endedAt: endedAt.toISOString(),
320
+ totalDurationMs: endedAt.getTime() - startedAt.getTime(),
321
+ friction: {
322
+ isFriction: false,
323
+ reasons: [],
324
+ thresholds: this.frictionThresholds,
325
+ metrics: {}
326
+ },
327
+ error: `Step "${currentStep?.id}" failed: ${err.message}`,
328
+ successReason: null,
329
+ validators: [],
330
+ softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 }
331
+ };
332
+ } finally {
333
+ page.removeListener('console', consoleHandler);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Execute a single step
339
+ */
340
+ async _executeStep(page, stepDef) {
341
+ const timeout = stepDef.timeout || this.timeout;
342
+
343
+ switch (stepDef.type) {
344
+ case 'navigate':
345
+ await page.goto(stepDef.target, {
346
+ waitUntil: 'domcontentloaded',
347
+ timeout
348
+ });
349
+ break;
350
+
351
+ case 'click':
352
+ // Try each selector in the target (semicolon-separated)
353
+ const selectors = stepDef.target.split(',').map(s => s.trim());
354
+ let clicked = false;
355
+
356
+ for (const selector of selectors) {
357
+ try {
358
+ await page.click(selector, { timeout: 5000 });
359
+ clicked = true;
360
+ break;
361
+ } catch (err) {
362
+ // Try next selector
363
+ }
364
+ }
365
+
366
+ if (!clicked) {
367
+ throw new Error(`Could not click element: ${stepDef.target}`);
368
+ }
369
+
370
+ // Wait for navigation if expected
371
+ if (stepDef.waitForNavigation) {
372
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
373
+ }
374
+ break;
375
+
376
+ case 'type':
377
+ // Try each selector
378
+ const typeSelectors = stepDef.target.split(',').map(s => s.trim());
379
+ let typed = false;
380
+
381
+ for (const selector of typeSelectors) {
382
+ try {
383
+ await page.fill(selector, stepDef.value, { timeout: 5000 });
384
+ typed = true;
385
+ break;
386
+ } catch (err) {
387
+ // Try next selector
388
+ }
389
+ }
390
+
391
+ if (!typed) {
392
+ throw new Error(`Could not type into element: ${stepDef.target}`);
393
+ }
394
+ break;
395
+
396
+ case 'waitFor':
397
+ const waitSelectors = stepDef.target.split(',').map(s => s.trim());
398
+ let found = false;
399
+
400
+ for (const selector of waitSelectors) {
401
+ try {
402
+ await page.waitForSelector(selector, {
403
+ timeout: stepDef.timeout || 5000,
404
+ state: stepDef.state || 'visible'
405
+ });
406
+ found = true;
407
+ break;
408
+ } catch (err) {
409
+ // Try next selector
410
+ }
411
+ }
412
+
413
+ if (!found) {
414
+ throw new Error(`Element not found: ${stepDef.target}`);
415
+ }
416
+ break;
417
+
418
+ case 'wait':
419
+ await page.waitForTimeout(stepDef.duration || 1000);
420
+ break;
421
+
422
+ default:
423
+ throw new Error(`Unknown step type: ${stepDef.type}`);
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Capture screenshot
429
+ */
430
+ async _captureScreenshot(page, artifactsDir, stepId) {
431
+ try {
432
+ const screenshotsDir = path.join(artifactsDir, 'attempt-screenshots');
433
+ if (!fs.existsSync(screenshotsDir)) {
434
+ fs.mkdirSync(screenshotsDir, { recursive: true });
435
+ }
436
+
437
+ const filename = `${stepId}.jpeg`;
438
+ const fullPath = path.join(screenshotsDir, filename);
439
+
440
+ await page.screenshot({
441
+ path: fullPath,
442
+ type: 'jpeg',
443
+ quality: 80,
444
+ fullPage: true
445
+ });
446
+
447
+ return filename;
448
+ } catch (err) {
449
+ return null;
450
+ }
451
+ }
452
+ }
453
+
454
+ module.exports = { AttemptEngine };
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Central registry for attempt definitions
3
+ * Phase 3: supports multiple curated attempts
4
+ */
5
+
6
+ const DEFAULT_ATTEMPTS = ['contact_form', 'language_switch', 'newsletter_signup', 'signup', 'login', 'checkout'];
7
+
8
+ const attemptDefinitions = {
9
+ contact_form: {
10
+ id: 'contact_form',
11
+ name: 'Contact Form Submission',
12
+ goal: 'User submits the contact form successfully',
13
+ riskCategory: 'LEAD',
14
+ baseSteps: [
15
+ { id: 'navigate_form', type: 'navigate', target: '$BASEURL', description: 'Navigate to contact form' },
16
+ {
17
+ id: 'open_contact_page',
18
+ type: 'click',
19
+ target: 'a[data-guardian="contact-link"], a[data-testid="contact-link"], a:has-text("Contact"), a[href*="/contact"]',
20
+ description: 'Open contact page',
21
+ optional: true,
22
+ waitForNavigation: true
23
+ },
24
+ { id: 'fill_name', type: 'type', target: 'input[name="name"], input[data-testid="name"], input[data-guardian="name"]', value: 'Test User', description: 'Enter name', timeout: 5000 },
25
+ { id: 'fill_email', type: 'type', target: 'input[name="email"], input[data-testid="email"], input[type="email"], input[data-guardian="email"]', value: 'test@example.com', description: 'Enter email', timeout: 5000 },
26
+ { id: 'fill_message', type: 'type', target: 'textarea[name="message"], textarea[data-testid="message"], input[name="message"], textarea[data-guardian="message"]', value: 'This is a test message.', description: 'Enter message', timeout: 5000 },
27
+ { id: 'submit_form', type: 'click', target: 'button[type="submit"], button:has-text("Submit"), button:has-text("Send"), button[data-guardian="submit"]', description: 'Submit form', timeout: 5000 }
28
+ ],
29
+ successConditions: [
30
+ { type: 'selector', target: '[data-guardian="success"], [data-testid="success"]', description: 'Success message element visible' }
31
+ ],
32
+ // Phase 2: Soft failure validators
33
+ validators: [
34
+ // Success indicators
35
+ { type: 'elementVisible', selector: '[data-guardian="success"], [data-testid="success"], .success-message, .alert-success' },
36
+ { type: 'pageContainsAnyText', textList: ['success', 'submitted', 'thank you', 'message received'] },
37
+ // Error/failure indicators that would make this a SOFT FAILURE
38
+ { type: 'elementNotVisible', selector: '.error, [role="alert"], .form-error, .error-message' }
39
+ ]
40
+ },
41
+
42
+ language_switch: {
43
+ id: 'language_switch',
44
+ name: 'Language Toggle',
45
+ goal: 'User switches language successfully',
46
+ riskCategory: 'TRUST/UX',
47
+ baseSteps: [
48
+ { id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
49
+ { id: 'open_language_page', type: 'click', target: 'a[data-guardian="language-link"], a:has-text("Language Switch")', description: 'Open language switch page', timeout: 5000 },
50
+ { id: 'open_language_toggle', type: 'click', target: '[data-guardian="lang-toggle"], button:has-text("Language"), button:has-text("Lang")', description: 'Open language toggle', timeout: 5000 },
51
+ { id: 'select_language', type: 'click', target: '[data-guardian="lang-option-de"], [data-testid="lang-option-de"], button:has-text("DE")', description: 'Switch to German', timeout: 5000 },
52
+ { id: 'verify_language', type: 'waitFor', target: '[data-guardian="lang-current"]:has-text("DE")', description: 'Wait for language to switch to DE', timeout: 5000 }
53
+ ],
54
+ successConditions: [
55
+ { type: 'selector', target: '[data-guardian="lang-current"]:has-text("DE")', description: 'Current language shows DE' },
56
+ { type: 'selector', target: '[data-guardian="lang-current"], [data-testid="lang-current"]', description: 'Language indicator visible' }
57
+ ],
58
+ // Phase 2: Language switch validators
59
+ validators: [
60
+ { type: 'htmlLangAttribute', lang: 'de' },
61
+ { type: 'pageContainsAnyText', textList: ['Deutsch', 'German', 'Sprache'] }
62
+ ]
63
+ },
64
+
65
+ newsletter_signup: {
66
+ id: 'newsletter_signup',
67
+ name: 'Newsletter Signup',
68
+ goal: 'User signs up for newsletter',
69
+ riskCategory: 'LEAD',
70
+ baseSteps: [
71
+ { id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
72
+ {
73
+ id: 'open_signup_page',
74
+ type: 'click',
75
+ target: 'a[data-guardian="signup-link"], a[data-testid="signup-link"], a:has-text("Sign up"), a:has-text("Signup"), a[href*="signup"], a[href*="newsletter"]',
76
+ description: 'Open signup page',
77
+ optional: true,
78
+ waitForNavigation: true
79
+ },
80
+ { id: 'fill_email', type: 'type', target: 'input[type="email"], input[data-guardian="signup-email"], input[data-testid="signup-email"]', value: 'subscriber@example.com', description: 'Enter email', timeout: 5000 },
81
+ { id: 'submit_signup', type: 'click', target: 'button[type="submit"], button[data-guardian="signup-submit"], button:has-text("Subscribe"), button:has-text("Sign up")', description: 'Submit signup', timeout: 5000 }
82
+ ],
83
+ successConditions: [
84
+ { type: 'selector', target: '[data-guardian="signup-success"], [data-testid="signup-success"]', description: 'Signup success message visible' }
85
+ ],
86
+ // Phase 2: Newsletter signup validators
87
+ validators: [
88
+ { type: 'elementVisible', selector: '[data-guardian="signup-success"], [data-testid="signup-success"], .signup-success, .toast-success' },
89
+ { type: 'pageContainsAnyText', textList: ['confirmed', 'subscribed', 'thank you', 'welcome', 'subscription'] },
90
+ { type: 'elementNotVisible', selector: '.error, [role="alert"], .signup-error, .error-message' }
91
+ ]
92
+ },
93
+
94
+ signup: {
95
+ id: 'signup',
96
+ name: 'Account Signup',
97
+ goal: 'User creates an account successfully',
98
+ riskCategory: 'LEAD',
99
+ baseSteps: [
100
+ { id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
101
+ { id: 'open_account_signup', type: 'click', target: 'a[data-guardian="account-signup-link"], a:has-text("Account Signup")', description: 'Open account signup', optional: true, waitForNavigation: true, timeout: 5000 },
102
+ { id: 'fill_signup_email', type: 'type', target: '[data-guardian="signup-email"]', value: 'newuser@example.com', description: 'Enter signup email', timeout: 5000 },
103
+ { id: 'fill_signup_password', type: 'type', target: '[data-guardian="signup-password"]', value: 'P@ssword123', description: 'Enter signup password', timeout: 5000 },
104
+ { id: 'submit_signup_account', type: 'click', target: '[data-guardian="signup-account-submit"], button:has-text("Sign up")', description: 'Submit account signup', timeout: 5000 },
105
+ { id: 'wait_signup_success', type: 'waitFor', target: '[data-guardian="signup-account-success"]', description: 'Wait for signup success', timeout: 7000 }
106
+ ],
107
+ successConditions: [
108
+ { type: 'selector', target: '[data-guardian="signup-account-success"]', description: 'Account signup success visible' }
109
+ ],
110
+ validators: [
111
+ { type: 'elementVisible', selector: '[data-guardian="signup-account-success"]' },
112
+ { type: 'pageContainsAnyText', textList: ['Account created', 'created', 'welcome aboard'] },
113
+ { type: 'elementNotVisible', selector: '[data-guardian="signup-account-error"], .error, [role="alert"]' }
114
+ ]
115
+ },
116
+
117
+ login: {
118
+ id: 'login',
119
+ name: 'Account Login',
120
+ goal: 'User logs in successfully (single session)',
121
+ riskCategory: 'TRUST/UX',
122
+ baseSteps: [
123
+ { id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
124
+ { id: 'open_login_page', type: 'click', target: 'a[data-guardian="account-login-link"], a:has-text("Login"), a[href*="login"]', description: 'Open login page', optional: true, waitForNavigation: true, timeout: 5000 },
125
+ { id: 'fill_login_email', type: 'type', target: '[data-guardian="login-email"]', value: 'user@example.com', description: 'Enter login email', timeout: 5000 },
126
+ { id: 'fill_login_password', type: 'type', target: '[data-guardian="login-password"]', value: 'password123', description: 'Enter login password', timeout: 5000 },
127
+ { id: 'submit_login', type: 'click', target: '[data-guardian="login-submit"], button:has-text("Login")', description: 'Submit login', timeout: 5000 },
128
+ { id: 'wait_login_success', type: 'waitFor', target: '[data-guardian="login-success"]', description: 'Wait for login success', timeout: 7000 }
129
+ ],
130
+ successConditions: [
131
+ { type: 'selector', target: '[data-guardian="login-success"]', description: 'Login success visible' }
132
+ ],
133
+ validators: [
134
+ { type: 'elementVisible', selector: '[data-guardian="login-success"]' },
135
+ { type: 'pageContainsAnyText', textList: ['logged in', 'welcome back', 'logged'] },
136
+ { type: 'elementNotVisible', selector: '[data-guardian="login-error"], .error, [role="alert"]' }
137
+ ]
138
+ },
139
+
140
+ checkout: {
141
+ id: 'checkout',
142
+ name: 'Checkout Review',
143
+ goal: 'User places order without payment',
144
+ riskCategory: 'REVENUE',
145
+ baseSteps: [
146
+ { id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
147
+ { id: 'open_checkout', type: 'click', target: 'a[data-guardian="checkout-link"], a:has-text("Checkout"), a[href*="checkout"]', description: 'Open checkout page', optional: true, waitForNavigation: true, timeout: 5000 },
148
+ { id: 'place_order', type: 'click', target: '[data-guardian="checkout-place-order"], button:has-text("Place order"), button:has-text("Place Order")', description: 'Place order', timeout: 5000 },
149
+ { id: 'wait_order_success', type: 'waitFor', target: '[data-guardian="checkout-success"]', description: 'Wait for order confirmation', timeout: 7000 }
150
+ ],
151
+ successConditions: [
152
+ { type: 'selector', target: '[data-guardian="checkout-success"]', description: 'Checkout success visible' }
153
+ ],
154
+ validators: [
155
+ { type: 'elementVisible', selector: '[data-guardian="checkout-success"]' },
156
+ { type: 'pageContainsAnyText', textList: ['order placed', 'order confirmed', 'order confirmed'] },
157
+ { type: 'elementNotVisible', selector: '[data-guardian="checkout-error"], .error, [role="alert"]' }
158
+ ]
159
+ },
160
+
161
+ // Universal Reality Pack: deterministic, zero-config safety checks
162
+ universal_reality: {
163
+ id: 'universal_reality',
164
+ name: 'Universal Reality Pack',
165
+ goal: 'Basic site usability and safety checks',
166
+ riskCategory: 'TRUST/UX',
167
+ baseSteps: [
168
+ { id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
169
+ { id: 'wait_for_body', type: 'waitFor', target: 'body', description: 'Ensure page body is visible', timeout: 5000 },
170
+ // Safe navigation probe (optional)
171
+ {
172
+ id: 'probe_safe_nav',
173
+ type: 'click',
174
+ target: 'nav a[href], header a[href], a[href]:not([href^="mailto:"]):not([href^="tel:"]):not([href^="javascript:"])',
175
+ description: 'Click a safe visible link',
176
+ optional: true,
177
+ waitForNavigation: true
178
+ }
179
+ ],
180
+ successConditions: [
181
+ // Consider success if body is visible (page loaded) or URL still valid
182
+ { type: 'selector', target: 'body', description: 'Page loaded and visible' }
183
+ ],
184
+ // Deterministic validators for soft issues
185
+ validators: [
186
+ // Minimal content heuristics
187
+ { type: 'elementVisible', selector: 'nav, header' },
188
+ { type: 'pageContainsAnyText', textList: ['home', 'about', 'contact', 'privacy', 'terms'] },
189
+ // Console errors as soft failures (WARN/FAIL recorded, outcome unchanged)
190
+ { type: 'noConsoleErrorsAbove', minSeverity: 'error' }
191
+ ]
192
+ }
193
+ };
194
+
195
+ function getAttemptDefinition(id) {
196
+ return attemptDefinitions[id] || null;
197
+ }
198
+
199
+ function getDefaultAttemptIds() {
200
+ return DEFAULT_ATTEMPTS.slice();
201
+ }
202
+
203
+ function listAttemptDefinitions() {
204
+ return Object.values(attemptDefinitions);
205
+ }
206
+
207
+ /**
208
+ * Phase 2: Dynamically register an auto-generated attempt
209
+ * @param {Object} attemptDef - Attempt definition with id, name, baseSteps, validators
210
+ */
211
+ function registerAttempt(attemptDef) {
212
+ if (!attemptDef || !attemptDef.id) {
213
+ throw new Error('Cannot register attempt: missing id');
214
+ }
215
+ if (attemptDefinitions[attemptDef.id]) {
216
+ console.warn(`⚠️ Attempt ${attemptDef.id} already registered, skipping`);
217
+ return;
218
+ }
219
+ attemptDefinitions[attemptDef.id] = attemptDef;
220
+ }
221
+
222
+ module.exports = {
223
+ getAttemptDefinition,
224
+ getDefaultAttemptIds,
225
+ listAttemptDefinitions,
226
+ registerAttempt
227
+ };