@odavl/guardian 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +155 -97
  3. package/bin/guardian.js +1345 -60
  4. package/config/README.md +59 -0
  5. package/config/profiles/landing-demo.yaml +16 -0
  6. package/package.json +21 -11
  7. package/policies/landing-demo.json +22 -0
  8. package/src/enterprise/audit-logger.js +166 -0
  9. package/src/enterprise/pdf-exporter.js +267 -0
  10. package/src/enterprise/rbac-gate.js +142 -0
  11. package/src/enterprise/rbac.js +239 -0
  12. package/src/enterprise/site-manager.js +180 -0
  13. package/src/founder/feedback-system.js +156 -0
  14. package/src/founder/founder-tracker.js +213 -0
  15. package/src/founder/usage-signals.js +141 -0
  16. package/src/guardian/alert-ledger.js +121 -0
  17. package/src/guardian/attempt-engine.js +568 -7
  18. package/src/guardian/attempt-registry.js +42 -1
  19. package/src/guardian/attempt-relevance.js +106 -0
  20. package/src/guardian/attempt.js +24 -0
  21. package/src/guardian/baseline.js +12 -4
  22. package/src/guardian/breakage-intelligence.js +1 -0
  23. package/src/guardian/ci-cli.js +121 -0
  24. package/src/guardian/ci-output.js +4 -3
  25. package/src/guardian/cli-summary.js +79 -92
  26. package/src/guardian/config-loader.js +162 -0
  27. package/src/guardian/drift-detector.js +100 -0
  28. package/src/guardian/enhanced-html-reporter.js +221 -4
  29. package/src/guardian/env-guard.js +127 -0
  30. package/src/guardian/failure-intelligence.js +173 -0
  31. package/src/guardian/first-run-profile.js +89 -0
  32. package/src/guardian/first-run.js +6 -1
  33. package/src/guardian/flag-validator.js +17 -3
  34. package/src/guardian/html-reporter.js +2 -0
  35. package/src/guardian/human-reporter.js +431 -0
  36. package/src/guardian/index.js +22 -19
  37. package/src/guardian/init-command.js +9 -5
  38. package/src/guardian/intent-detector.js +146 -0
  39. package/src/guardian/journey-definitions.js +132 -0
  40. package/src/guardian/journey-scan-cli.js +145 -0
  41. package/src/guardian/journey-scanner.js +583 -0
  42. package/src/guardian/junit-reporter.js +18 -1
  43. package/src/guardian/live-cli.js +95 -0
  44. package/src/guardian/live-scheduler-runner.js +137 -0
  45. package/src/guardian/live-scheduler.js +146 -0
  46. package/src/guardian/market-reporter.js +341 -81
  47. package/src/guardian/pattern-analyzer.js +348 -0
  48. package/src/guardian/policy.js +80 -3
  49. package/src/guardian/preset-loader.js +9 -6
  50. package/src/guardian/reality.js +1278 -117
  51. package/src/guardian/reporter.js +27 -41
  52. package/src/guardian/run-artifacts.js +212 -0
  53. package/src/guardian/run-cleanup.js +207 -0
  54. package/src/guardian/run-latest.js +90 -0
  55. package/src/guardian/run-list.js +211 -0
  56. package/src/guardian/scan-presets.js +100 -11
  57. package/src/guardian/selector-fallbacks.js +394 -0
  58. package/src/guardian/semantic-contact-finder.js +2 -1
  59. package/src/guardian/site-introspection.js +257 -0
  60. package/src/guardian/smoke.js +2 -2
  61. package/src/guardian/snapshot-schema.js +25 -1
  62. package/src/guardian/snapshot.js +46 -2
  63. package/src/guardian/stability-scorer.js +169 -0
  64. package/src/guardian/template-command.js +184 -0
  65. package/src/guardian/text-formatters.js +426 -0
  66. package/src/guardian/verdict.js +320 -0
  67. package/src/guardian/verdicts.js +74 -0
  68. package/src/guardian/watch-runner.js +3 -7
  69. package/src/payments/stripe-checkout.js +169 -0
  70. package/src/plans/plan-definitions.js +148 -0
  71. package/src/plans/plan-manager.js +211 -0
  72. package/src/plans/usage-tracker.js +210 -0
  73. package/src/recipes/recipe-engine.js +188 -0
  74. package/src/recipes/recipe-failure-analysis.js +159 -0
  75. package/src/recipes/recipe-registry.js +134 -0
  76. package/src/recipes/recipe-runtime.js +507 -0
  77. package/src/recipes/recipe-store.js +410 -0
  78. package/guardian-contract-v1.md +0 -149
  79. /package/{guardian.config.json → config/guardian.config.json} +0 -0
  80. /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
  81. /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
  82. /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
  83. /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
  84. /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Robust Selector Discovery with Fallbacks
3
+ *
4
+ * Attempts to find elements using multiple strategies:
5
+ * 1. data-guardian attributes (highest priority if site is instrumented)
6
+ * 2. Semantic input types (email/password/text/submit)
7
+ * 3. Autocomplete attributes (email/current-password/new-password)
8
+ * 4. Name/ID contains patterns
9
+ * 5. Role and ARIA attributes
10
+ * 6. Link href patterns
11
+ * 7. Visible text matching (case-insensitive)
12
+ * 8. Heuristic form/button detection
13
+ *
14
+ * Returns: { element, strategy, selector, confidence, discoverySignals }
15
+ * discoverySignals includes: formsCounted, linksFound, buttonsCounted, finalSelection
16
+ */
17
+
18
+ /**
19
+ * Build selector chain for a specific element type
20
+ * @param {string} goalType - email_input, password_input, submit_button, login_link, contact_link, language_toggle, etc.
21
+ * @returns {string[]} Array of selectors in priority order
22
+ */
23
+ function buildSelectorChain(goalType) {
24
+ const chains = {
25
+ email_input: [
26
+ // data-guardian (site instrumented)
27
+ '[data-guardian="email"], [data-guardian="login-email"], [data-guardian="signup-email"]',
28
+ // semantic type
29
+ 'input[type="email"]',
30
+ // autocomplete
31
+ 'input[autocomplete="email"], input[autocomplete*="email"]',
32
+ // name/id patterns
33
+ 'input[name*="email" i], input[id*="email" i]',
34
+ 'input[name*="mail" i], input[id*="mail" i]',
35
+ 'input[name*="user" i], input[id*="user" i]',
36
+ // placeholder
37
+ 'input[placeholder*="email" i], input[placeholder*="mail" i]',
38
+ // aria-label
39
+ 'input[aria-label*="email" i], input[aria-label*="mail" i]',
40
+ // broad form field with label
41
+ 'form input[type="text"]:first-of-type'
42
+ ],
43
+
44
+ password_input: [
45
+ '[data-guardian="password"], [data-guardian="login-password"], [data-guardian="signup-password"]',
46
+ 'input[type="password"]',
47
+ 'input[autocomplete="current-password"], input[autocomplete="new-password"]',
48
+ 'input[name*="pass" i], input[id*="pass" i]',
49
+ 'input[name*="pwd" i], input[id*="pwd" i]',
50
+ 'input[placeholder*="pass" i], input[placeholder*="pwd" i]',
51
+ 'input[aria-label*="pass" i], input[aria-label*="pwd" i]'
52
+ ],
53
+
54
+ submit_button: [
55
+ '[data-guardian="submit"], [data-guardian="login-submit"], [data-guardian="signup-submit"], [data-guardian="signup-account-submit"]',
56
+ 'button[type="submit"]',
57
+ 'button:has-text("Sign in") >> nth=0',
58
+ 'button:has-text("Sign up") >> nth=0',
59
+ 'button:has-text("Log in") >> nth=0',
60
+ 'button:has-text("Register") >> nth=0',
61
+ 'button:has-text("Submit") >> nth=0',
62
+ 'button:has-text("Send") >> nth=0',
63
+ 'button:has-text("Create account") >> nth=0',
64
+ 'button[name*="submit" i], button[id*="submit" i]',
65
+ 'input[type="submit"], input[type="button"][value*="Submit" i], input[type="button"][value*="Sign" i]',
66
+ 'button:not([type="reset"]):not([type="button"]):visible >> nth=0'
67
+ ],
68
+
69
+ contact_form: [
70
+ '[data-guardian="contact-form"]',
71
+ 'form[name*="contact" i]',
72
+ 'form[id*="contact" i]',
73
+ 'form[class*="contact" i]',
74
+ '[role="form"][aria-label*="contact" i]',
75
+ 'form >> nth=0' // Fallback: first form on page
76
+ ],
77
+
78
+ contact_link: [
79
+ '[data-guardian="contact-link"]',
80
+ 'a[data-testid="contact-link"]',
81
+ 'a:has-text("Contact")',
82
+ 'a:has-text("Contact Us")',
83
+ 'a:has-text("Get in Touch")',
84
+ 'a[href*="/contact" i]',
85
+ 'a[href*="/contact-us" i]',
86
+ 'a[href*="/support" i]',
87
+ 'a[href="#contact" i]',
88
+ 'nav a:has-text("Contact")',
89
+ '[role="navigation"] a:has-text("Contact")'
90
+ ],
91
+
92
+ login_link: [
93
+ '[data-guardian="account-login-link"]',
94
+ 'a:has-text("Log in")',
95
+ 'a:has-text("Login")',
96
+ 'a:has-text("Sign in")',
97
+ 'a[href*="/login" i]',
98
+ 'a[href*="/signin" i]',
99
+ 'a[href*="/auth" i]',
100
+ 'a[href*="/account" i]',
101
+ 'nav a:has-text("Log in")'
102
+ ],
103
+
104
+ signup_link: [
105
+ '[data-guardian="account-signup-link"]',
106
+ 'a:has-text("Sign up")',
107
+ 'a:has-text("Signup")',
108
+ 'a:has-text("Register")',
109
+ 'a:has-text("Create account")',
110
+ 'a[href*="/signup" i]',
111
+ 'a[href*="/sign-up" i]',
112
+ 'a[href*="/register" i]',
113
+ 'a[href*="/join" i]',
114
+ 'nav a:has-text("Sign up")'
115
+ ],
116
+
117
+ checkout_link: [
118
+ '[data-guardian="checkout-link"]',
119
+ 'a:has-text("Checkout")',
120
+ 'a:has-text("Check out")',
121
+ 'a:has-text("Cart")',
122
+ 'a[href*="/checkout" i]',
123
+ 'a[href*="/cart" i]',
124
+ 'a[href*="/order" i]',
125
+ 'a[href*="/buy" i]'
126
+ ],
127
+
128
+ checkout_button: [
129
+ '[data-guardian="checkout-place-order"]',
130
+ 'button:has-text("Checkout")',
131
+ 'button:has-text("Place order")',
132
+ 'button:has-text("Place Order")',
133
+ 'button:has-text("Complete purchase")',
134
+ 'button:has-text("Buy now")',
135
+ 'button[name*="checkout" i]'
136
+ ],
137
+
138
+ language_toggle: [
139
+ '[data-guardian="lang-toggle"]',
140
+ 'button:has-text("Language")',
141
+ 'button:has-text("Lang")',
142
+ 'button[aria-label*="language" i]',
143
+ 'button[aria-label*="lang" i]',
144
+ 'select[name*="lang" i]',
145
+ 'select[id*="language" i]',
146
+ 'a[hreflang]:first-of-type'
147
+ ],
148
+
149
+ language_option_de: [
150
+ '[data-guardian="lang-option-de"]',
151
+ 'button:has-text("DE")',
152
+ 'button:has-text("Deutsch")',
153
+ 'a[hreflang="de"]',
154
+ '[lang="de"] >> nth=0'
155
+ ],
156
+
157
+ success_message: [
158
+ '[data-guardian="success"]',
159
+ '[data-testid="success"]',
160
+ '.success-message',
161
+ '.alert-success',
162
+ '[role="alert"]:has-text("success")',
163
+ '[role="alert"]:has-text("submitted")',
164
+ '[role="alert"]:has-text("confirmed")',
165
+ 'div:has-text("success")'
166
+ ]
167
+ };
168
+
169
+ return chains[goalType] || [];
170
+ }
171
+
172
+ /**
173
+ * Attempt to find element using selector chain
174
+ * @param {Page} page - Playwright page
175
+ * @param {string[]} selectorChain - Array of selectors to try
176
+ * @param {Object} options - { timeout, requireVisible }
177
+ * @returns {Promise<{element, strategy, selector, confidence, discoverySignals}>}
178
+ */
179
+ async function findElement(page, selectorChain, options = {}) {
180
+ const { timeout = 5000, requireVisible = true } = options;
181
+ const discoverySignals = {
182
+ formsCounted: 0,
183
+ linksCounted: 0,
184
+ buttonsCounted: 0,
185
+ candidatesEvaluated: [],
186
+ finalSelection: null
187
+ };
188
+
189
+ if (!selectorChain || selectorChain.length === 0) {
190
+ return {
191
+ element: null,
192
+ strategy: 'EMPTY_CHAIN',
193
+ selector: null,
194
+ confidence: 0,
195
+ discoverySignals
196
+ };
197
+ }
198
+
199
+ // Quick scan for discovery signals
200
+ try {
201
+ discoverySignals.formsCounted = (await page.locator('form').count()).toString();
202
+ discoverySignals.linksCounted = (await page.locator('a').count()).toString();
203
+ discoverySignals.buttonsCounted = (await page.locator('button').count()).toString();
204
+ } catch (_) {
205
+ // Non-critical
206
+ }
207
+
208
+ // Try each selector in chain
209
+ for (let i = 0; i < selectorChain.length; i++) {
210
+ const selector = selectorChain[i];
211
+ try {
212
+ const locator = page.locator(selector);
213
+ const count = await locator.count();
214
+
215
+ if (count > 0) {
216
+ // Found candidate(s)
217
+ discoverySignals.candidatesEvaluated.push({
218
+ strategy: i,
219
+ selector,
220
+ found: count
221
+ });
222
+
223
+ // Use first match
224
+ const element = locator.first();
225
+
226
+ // Check visibility if required
227
+ if (requireVisible) {
228
+ const isVisible = await element.isVisible().catch(() => false);
229
+ if (!isVisible) {
230
+ discoverySignals.candidatesEvaluated[discoverySignals.candidatesEvaluated.length - 1].visible = false;
231
+ continue; // Try next selector
232
+ }
233
+ }
234
+
235
+ discoverySignals.finalSelection = {
236
+ strategy: i,
237
+ selector,
238
+ found: count,
239
+ used: 'first'
240
+ };
241
+
242
+ return {
243
+ element,
244
+ strategy: `FALLBACK_${i}`,
245
+ selector,
246
+ confidence: 0.95 - (i * 0.05), // Decrease confidence for later strategies
247
+ discoverySignals
248
+ };
249
+ }
250
+ } catch (err) {
251
+ // Selector parse error or timeout - try next
252
+ discoverySignals.candidatesEvaluated.push({
253
+ strategy: i,
254
+ selector,
255
+ error: err.message
256
+ });
257
+ continue;
258
+ }
259
+ }
260
+
261
+ // No element found
262
+ return {
263
+ element: null,
264
+ strategy: 'NOT_FOUND',
265
+ selector: null,
266
+ confidence: 0,
267
+ discoverySignals
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Detect if a specific feature is present on the page
273
+ * @param {Page} page - Playwright page
274
+ * @param {string} featureType - 'login', 'signup', 'checkout', 'contact_form', 'newsletter', 'language_switch'
275
+ * @returns {Promise<{present: boolean, confidence: number, evidence: string[]}>}
276
+ */
277
+ async function detectFeature(page, featureType) {
278
+ const evidence = [];
279
+
280
+ try {
281
+ switch (featureType) {
282
+ case 'login': {
283
+ // Look for login form, login link, or auth elements
284
+ const hasLoginLink = (await page.locator('a:has-text("Log in"), a:has-text("Login"), a:has-text("Sign in")').count()) > 0;
285
+ const hasLoginForm = (await page.locator('input[type="email"] + input[type="password"]').count()) > 0;
286
+ const hasAuthSection = (await page.locator('[role="form"][aria-label*="login" i]').count()) > 0;
287
+
288
+ if (hasLoginLink) evidence.push('login_link_found');
289
+ if (hasLoginForm) evidence.push('auth_form_found');
290
+ if (hasAuthSection) evidence.push('auth_section_found');
291
+
292
+ return {
293
+ present: hasLoginLink || hasLoginForm || hasAuthSection,
294
+ confidence: evidence.length / 3,
295
+ evidence
296
+ };
297
+ }
298
+
299
+ case 'signup': {
300
+ const hasSignupLink = (await page.locator('a:has-text("Sign up"), a:has-text("Signup"), a:has-text("Register")').count()) > 0;
301
+ const hasSignupForm = (await page.locator('input[type="email"]').count()) > 0; // Weak signal
302
+ const hasCreateText = (await page.locator('text=/create.*account/i').count()) > 0;
303
+
304
+ if (hasSignupLink) evidence.push('signup_link_found');
305
+ if (hasSignupForm) evidence.push('email_field_found');
306
+ if (hasCreateText) evidence.push('create_account_text_found');
307
+
308
+ return {
309
+ present: hasSignupLink || (hasSignupForm && hasCreateText),
310
+ confidence: evidence.length / 3,
311
+ evidence
312
+ };
313
+ }
314
+
315
+ case 'checkout': {
316
+ const hasCheckoutLink = (await page.locator('a:has-text("Checkout"), a:has-text("Cart"), a[href*="/checkout" i]').count()) > 0;
317
+ const hasPriceElements = (await page.locator('text=/\\$|€|¥|£/').count()) > 0;
318
+ const hasAddToCart = (await page.locator('button:has-text("Add to cart"), a:has-text("Add to cart")').count()) > 0;
319
+
320
+ if (hasCheckoutLink) evidence.push('checkout_link_found');
321
+ if (hasPriceElements) evidence.push('price_indicators_found');
322
+ if (hasAddToCart) evidence.push('add_to_cart_found');
323
+
324
+ return {
325
+ present: hasCheckoutLink || (hasPriceElements && hasAddToCart),
326
+ confidence: evidence.length / 3,
327
+ evidence
328
+ };
329
+ }
330
+
331
+ case 'contact_form': {
332
+ const hasContactForm = (await page.locator('form[name*="contact" i], form[id*="contact" i], [role="form"][aria-label*="contact"]').count()) > 0;
333
+ const hasContactLink = (await page.locator('a:has-text("Contact")').count()) > 0;
334
+ const hasEmailField = (await page.locator('input[type="email"]').count()) > 0;
335
+ const hasMessageField = (await page.locator('textarea').count()) > 0;
336
+
337
+ if (hasContactForm) evidence.push('contact_form_found');
338
+ if (hasContactLink) evidence.push('contact_link_found');
339
+ if (hasEmailField && hasMessageField) evidence.push('contact_form_elements_found');
340
+
341
+ return {
342
+ present: hasContactForm || hasContactLink || (hasEmailField && hasMessageField),
343
+ confidence: evidence.length / 3,
344
+ evidence
345
+ };
346
+ }
347
+
348
+ case 'newsletter': {
349
+ const hasNewsletterForm = (await page.locator('form[name*="newsletter" i], [aria-label*="newsletter" i]').count()) > 0;
350
+ const hasSubscribeButton = (await page.locator('button:has-text("Subscribe"), button:has-text("Sign up")').count()) > 0;
351
+ const hasNewsletterText = (await page.locator('text=/newsletter|subscribe/i').count()) > 0;
352
+
353
+ if (hasNewsletterForm) evidence.push('newsletter_form_found');
354
+ if (hasSubscribeButton) evidence.push('subscribe_button_found');
355
+ if (hasNewsletterText) evidence.push('newsletter_text_found');
356
+
357
+ return {
358
+ present: hasNewsletterForm || (hasSubscribeButton && hasNewsletterText),
359
+ confidence: evidence.length / 3,
360
+ evidence
361
+ };
362
+ }
363
+
364
+ case 'language_switch': {
365
+ const hasLangButton = (await page.locator('button[aria-label*="language" i], button:has-text("Language")').count()) > 0;
366
+ const hasLangSelect = (await page.locator('select[name*="lang" i]').count()) > 0;
367
+ const hasMultipleLangs = (await page.locator('a[hreflang]').count()) > 1;
368
+ const hasLangIndicator = (await page.locator('text=/en|de|fr|es|it|pt/i').count()) > 0;
369
+
370
+ if (hasLangButton) evidence.push('lang_button_found');
371
+ if (hasLangSelect) evidence.push('lang_select_found');
372
+ if (hasMultipleLangs) evidence.push('hreflang_links_found');
373
+ if (hasLangIndicator) evidence.push('lang_codes_found');
374
+
375
+ return {
376
+ present: hasLangButton || hasLangSelect || hasMultipleLangs,
377
+ confidence: evidence.length / 4,
378
+ evidence
379
+ };
380
+ }
381
+
382
+ default:
383
+ return { present: false, confidence: 0, evidence: [] };
384
+ }
385
+ } catch (err) {
386
+ return { present: false, confidence: 0, evidence: [`error: ${err.message}`] };
387
+ }
388
+ }
389
+
390
+ module.exports = {
391
+ buildSelectorChain,
392
+ findElement,
393
+ detectFeature
394
+ };
@@ -180,7 +180,8 @@ function formatDetectionForReport(detectionResult) {
180
180
  });
181
181
  } else {
182
182
  lines.push('');
183
- lines.push(`❌ No ${detectionResult.target || 'target'} found`);
183
+ // Clarify selector-based scope to avoid overstating discovery
184
+ lines.push(`❌ No ${detectionResult.target || 'contact'} page/link discovered via selectors`);
184
185
  if (detectionResult.reason) {
185
186
  lines.push(` Reason: ${detectionResult.reason}`);
186
187
  }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Site Introspection - DOM-based capability detection
3
+ * Deterministically identifies site features by inspecting the loaded page.
4
+ */
5
+
6
+ /**
7
+ * Inspect the page for various capabilities
8
+ *
9
+ * @param {Page} page - Playwright page object (already loaded)
10
+ * @returns {Promise<Object>} introspection results
11
+ */
12
+ async function inspectSite(page) {
13
+ const introspection = {
14
+ hasLogin: false,
15
+ hasSignup: false,
16
+ hasCheckout: false,
17
+ hasNewsletter: false,
18
+ hasContactForm: false,
19
+ hasLanguageSwitch: false,
20
+ hasContentSignals: false
21
+ };
22
+
23
+ try {
24
+ // Check for forms - basic existence
25
+ const hasForms = await page.evaluate(() => {
26
+ return document.querySelectorAll('form').length > 0;
27
+ });
28
+
29
+ // Check for login indicators — STRONG SIGNALS ONLY
30
+ introspection.hasLogin = await page.evaluate(() => {
31
+ const isValidHrefForAuth = (href) => {
32
+ if (!href) return false;
33
+ const h = href.trim().toLowerCase();
34
+ if (h.startsWith('javascript:')) return false;
35
+ if (h.startsWith('#')) return false;
36
+ try {
37
+ const u = new URL(h, window.location.origin);
38
+ const p = u.pathname.toLowerCase();
39
+ return /(\/login|\/signin|\/sign-in|\/auth\/login)$|\/(login|signin)(\/|$)/.test(p);
40
+ } catch (_) {
41
+ return false;
42
+ }
43
+ };
44
+
45
+ // Strong signal 1: password input on page
46
+ const hasPasswordInput = document.querySelectorAll('input[type="password"]').length > 0;
47
+
48
+ // Strong signal 2: a form containing a password field
49
+ const formWithPassword = Array.from(document.querySelectorAll('form')).some(f => f.querySelector('input[type="password"]'));
50
+
51
+ // Strong signal 3: link/button to common auth routes
52
+ const authRouteLink = Array.from(document.querySelectorAll('a')).some(a => isValidHrefForAuth(a.getAttribute('href')));
53
+
54
+ return hasPasswordInput || formWithPassword || authRouteLink;
55
+ });
56
+
57
+ // Check for signup indicators — STRONG SIGNALS ONLY
58
+ introspection.hasSignup = await page.evaluate(() => {
59
+ const isValidHrefForSignup = (href) => {
60
+ if (!href) return false;
61
+ const h = href.trim().toLowerCase();
62
+ if (h.startsWith('javascript:')) return false;
63
+ if (h.startsWith('#')) return false;
64
+ try {
65
+ const u = new URL(h, window.location.origin);
66
+ const p = u.pathname.toLowerCase();
67
+ return /(\/signup|\/register|\/sign-up|\/auth\/signup)$|\/(signup|register|sign-up)(\/|$)/.test(p);
68
+ } catch (_) {
69
+ return false;
70
+ }
71
+ };
72
+
73
+ // Strong signal 1: form that contains a password field AND signup/register text
74
+ const formWithPasswordAndSignupText = Array.from(document.querySelectorAll('form')).some(form => {
75
+ const hasPwd = !!form.querySelector('input[type="password"]');
76
+ if (!hasPwd) return false;
77
+ const txt = (form.textContent || '').toLowerCase();
78
+ return /\b(sign ?up|register|create account|join|get started)\b/.test(txt);
79
+ });
80
+
81
+ // Strong signal 2: auth route link to signup/register paths
82
+ const authRouteLink = Array.from(document.querySelectorAll('a')).some(a => isValidHrefForSignup(a.getAttribute('href')));
83
+
84
+ return formWithPasswordAndSignupText || authRouteLink;
85
+ });
86
+
87
+ // Check for checkout/cart indicators — STRONG SIGNALS ONLY
88
+ introspection.hasCheckout = await page.evaluate(() => {
89
+ const isValidHrefForCheckout = (href) => {
90
+ if (!href) return false;
91
+ const h = href.trim().toLowerCase();
92
+ if (h.startsWith('javascript:')) return false;
93
+ if (h.startsWith('#')) return false;
94
+ try {
95
+ const u = new URL(h, window.location.origin);
96
+ const p = u.pathname.toLowerCase();
97
+ // Strong checkout/cart routes
98
+ return /(\/cart|\/checkout|\/basket)$|\/(cart|checkout|basket)(\/|$)/.test(p);
99
+ } catch (_) {
100
+ return false;
101
+ }
102
+ };
103
+
104
+ // Strong signal 1: auth route links to cart/checkout/basket
105
+ const routeLink = Array.from(document.querySelectorAll('a')).some(a => isValidHrefForCheckout(a.getAttribute('href')));
106
+
107
+ // Strong signal 2: buttons with explicit commerce actions
108
+ const commerceButtons = Array.from(document.querySelectorAll('button, input[type="submit"]')).some(btn => {
109
+ const text = (btn.textContent || btn.value || '').toLowerCase();
110
+ return /\b(add to cart|buy now|checkout|purchase)\b/.test(text);
111
+ });
112
+
113
+ // Strong signal 3: explicit cart identifiers
114
+ const cartIndicators = Array.from(document.querySelectorAll('[id*="cart" i], [class*="cart" i], [class*="basket" i]')).length > 0;
115
+
116
+ return routeLink || commerceButtons || cartIndicators;
117
+ });
118
+
119
+ // Check for newsletter signup
120
+ introspection.hasNewsletter = await page.evaluate(() => {
121
+ // Check for newsletter-specific inputs
122
+ const hasNewsletterInput = Array.from(document.querySelectorAll('input[type="email"]')).some(input => {
123
+ const placeholder = (input.placeholder || '').toLowerCase();
124
+ const id = (input.id || '').toLowerCase();
125
+ const name = (input.name || '').toLowerCase();
126
+ return placeholder.match(/newsletter|subscribe|email/) ||
127
+ id.match(/newsletter|subscribe/) ||
128
+ name.match(/newsletter|subscribe/);
129
+ });
130
+
131
+ // Check for newsletter text
132
+ const hasNewsletterText = Array.from(document.querySelectorAll('form, div')).some(el => {
133
+ const text = (el.textContent || '').toLowerCase();
134
+ return text.match(/\b(newsletter|subscribe to|stay updated|get updates)\b/);
135
+ });
136
+
137
+ return hasNewsletterInput || hasNewsletterText;
138
+ });
139
+
140
+ // Check for contact form
141
+ introspection.hasContactForm = await page.evaluate(() => {
142
+ // Check links
143
+ const contactLinks = Array.from(document.querySelectorAll('a')).some(a => {
144
+ const text = (a.textContent || '').toLowerCase();
145
+ const href = (a.href || '').toLowerCase();
146
+ return text.match(/\b(contact|contact us|get in touch)\b/) ||
147
+ href.match(/\/contact/);
148
+ });
149
+
150
+ // Check for forms with contact-related fields
151
+ const hasContactForm = Array.from(document.querySelectorAll('form')).some(form => {
152
+ const formText = (form.textContent || '').toLowerCase();
153
+ const hasNameField = form.querySelectorAll('input[name*="name"]').length > 0;
154
+ const hasEmailField = form.querySelectorAll('input[type="email"]').length > 0;
155
+ const hasMessageField = form.querySelectorAll('textarea').length > 0;
156
+ return formText.match(/contact|message|inquiry/) && hasNameField && hasEmailField && hasMessageField;
157
+ });
158
+
159
+ return contactLinks || hasContactForm;
160
+ });
161
+
162
+ // Check for language switch
163
+ introspection.hasLanguageSwitch = await page.evaluate(() => {
164
+ // Check for language selectors
165
+ const hasLangSelect = Array.from(document.querySelectorAll('select')).some(select => {
166
+ const id = (select.id || '').toLowerCase();
167
+ const name = (select.name || '').toLowerCase();
168
+ return id.match(/lang|language/) || name.match(/lang|language/);
169
+ });
170
+
171
+ // Check for language links (common patterns)
172
+ const hasLangLinks = Array.from(document.querySelectorAll('a, button')).some(el => {
173
+ const text = (el.textContent || '').toLowerCase().trim();
174
+ const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
175
+ // Common language codes
176
+ return text.match(/^(en|es|fr|de|it|pt|ja|zh|ko|ru)$/i) ||
177
+ ariaLabel.match(/language|lang/) ||
178
+ text.match(/\b(english|español|français|deutsch)\b/i);
179
+ });
180
+
181
+ // Check for globe icon (common language switch indicator)
182
+ const hasGlobeIcon = Array.from(document.querySelectorAll('[class*="globe"], [class*="lang"], [class*="language"]')).length > 0;
183
+
184
+ return hasLangSelect || hasLangLinks || hasGlobeIcon;
185
+ });
186
+
187
+ // Content signals (generic): many internal links + article-like structure
188
+ introspection.hasContentSignals = await page.evaluate(() => {
189
+ try {
190
+ const originHost = window.location.hostname;
191
+ const internalLinks = Array.from(document.querySelectorAll('a')).filter(a => {
192
+ const href = a.getAttribute('href');
193
+ if (!href) return false;
194
+ const h = href.trim().toLowerCase();
195
+ if (h.startsWith('javascript:')) return false;
196
+ if (h.startsWith('#')) return false;
197
+ try {
198
+ const u = new URL(h, window.location.origin);
199
+ return u.hostname === originHost;
200
+ } catch (_) {
201
+ return false;
202
+ }
203
+ });
204
+
205
+ const mainEl = document.querySelector('main') || document.querySelector('article');
206
+ const paragraphCount = mainEl ? mainEl.querySelectorAll('p').length : 0;
207
+
208
+ const manyInternalLinks = internalLinks.length >= 20;
209
+ const hasArticleStructure = paragraphCount >= 10;
210
+
211
+ // Tiny special-case acceptable for Wikipedia (high confidence content site)
212
+ const isWikipedia = /(^|\.)wikipedia\.org$/.test(window.location.hostname);
213
+
214
+ return (manyInternalLinks && hasArticleStructure) || isWikipedia;
215
+ } catch (_) {
216
+ return false;
217
+ }
218
+ });
219
+
220
+ } catch (error) {
221
+ // If introspection fails, return all false (fail-safe)
222
+ console.warn(`[Introspection] Error during site inspection: ${error.message}`);
223
+ }
224
+
225
+ return introspection;
226
+ }
227
+
228
+ /**
229
+ * Detect site profile based on introspection results
230
+ *
231
+ * @param {Object} introspection - Result from inspectSite()
232
+ * @returns {string} Profile: 'ecommerce', 'saas', 'content', or 'unknown'
233
+ */
234
+ function detectProfile(introspection) {
235
+ // E-commerce: strong checkout/cart signals
236
+ if (introspection.hasCheckout) {
237
+ return 'ecommerce';
238
+ }
239
+
240
+ // SaaS: strong auth signals (login/signup) and no checkout
241
+ if ((introspection.hasLogin || introspection.hasSignup)) {
242
+ return 'saas';
243
+ }
244
+
245
+ // Content site: absence of ecommerce & saas; presence of content signals
246
+ if (introspection.hasLanguageSwitch || introspection.hasContactForm || introspection.hasContentSignals) {
247
+ return 'content';
248
+ }
249
+
250
+ // Unknown: nothing detected
251
+ return 'unknown';
252
+ }
253
+
254
+ module.exports = {
255
+ inspectSite,
256
+ detectProfile
257
+ };
@@ -69,11 +69,11 @@ async function executeSmoke(config) {
69
69
  const parallel = parallelValidation.parallel || DEFAULT_PARALLEL;
70
70
 
71
71
  if (!ciMode) {
72
- console.log('\nSMOKE MODE: ON');
72
+ console.log('\nSMOKE MODE: Fast market sanity check (<30s)');
73
73
  console.log(`Target: ${baseUrl}`);
74
74
  console.log(`Attempts: ${SMOKE_ATTEMPTS.join(', ')}`);
75
75
  } else {
76
- console.log('SMOKE MODE: ON');
76
+ console.log('SMOKE MODE: Fast market sanity check (<30s)');
77
77
  console.log(`Target: ${baseUrl}`);
78
78
  console.log(`Attempts: ${SMOKE_ATTEMPTS.join(', ')}`);
79
79
  }