@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.
- package/CHANGELOG.md +86 -2
- package/README.md +155 -97
- package/bin/guardian.js +1345 -60
- package/config/README.md +59 -0
- package/config/profiles/landing-demo.yaml +16 -0
- package/package.json +21 -11
- package/policies/landing-demo.json +22 -0
- package/src/enterprise/audit-logger.js +166 -0
- package/src/enterprise/pdf-exporter.js +267 -0
- package/src/enterprise/rbac-gate.js +142 -0
- package/src/enterprise/rbac.js +239 -0
- package/src/enterprise/site-manager.js +180 -0
- package/src/founder/feedback-system.js +156 -0
- package/src/founder/founder-tracker.js +213 -0
- package/src/founder/usage-signals.js +141 -0
- package/src/guardian/alert-ledger.js +121 -0
- package/src/guardian/attempt-engine.js +568 -7
- package/src/guardian/attempt-registry.js +42 -1
- package/src/guardian/attempt-relevance.js +106 -0
- package/src/guardian/attempt.js +24 -0
- package/src/guardian/baseline.js +12 -4
- package/src/guardian/breakage-intelligence.js +1 -0
- package/src/guardian/ci-cli.js +121 -0
- package/src/guardian/ci-output.js +4 -3
- package/src/guardian/cli-summary.js +79 -92
- package/src/guardian/config-loader.js +162 -0
- package/src/guardian/drift-detector.js +100 -0
- package/src/guardian/enhanced-html-reporter.js +221 -4
- package/src/guardian/env-guard.js +127 -0
- package/src/guardian/failure-intelligence.js +173 -0
- package/src/guardian/first-run-profile.js +89 -0
- package/src/guardian/first-run.js +6 -1
- package/src/guardian/flag-validator.js +17 -3
- package/src/guardian/html-reporter.js +2 -0
- package/src/guardian/human-reporter.js +431 -0
- package/src/guardian/index.js +22 -19
- package/src/guardian/init-command.js +9 -5
- package/src/guardian/intent-detector.js +146 -0
- package/src/guardian/journey-definitions.js +132 -0
- package/src/guardian/journey-scan-cli.js +145 -0
- package/src/guardian/journey-scanner.js +583 -0
- package/src/guardian/junit-reporter.js +18 -1
- package/src/guardian/live-cli.js +95 -0
- package/src/guardian/live-scheduler-runner.js +137 -0
- package/src/guardian/live-scheduler.js +146 -0
- package/src/guardian/market-reporter.js +341 -81
- package/src/guardian/pattern-analyzer.js +348 -0
- package/src/guardian/policy.js +80 -3
- package/src/guardian/preset-loader.js +9 -6
- package/src/guardian/reality.js +1278 -117
- package/src/guardian/reporter.js +27 -41
- package/src/guardian/run-artifacts.js +212 -0
- package/src/guardian/run-cleanup.js +207 -0
- package/src/guardian/run-latest.js +90 -0
- package/src/guardian/run-list.js +211 -0
- package/src/guardian/scan-presets.js +100 -11
- package/src/guardian/selector-fallbacks.js +394 -0
- package/src/guardian/semantic-contact-finder.js +2 -1
- package/src/guardian/site-introspection.js +257 -0
- package/src/guardian/smoke.js +2 -2
- package/src/guardian/snapshot-schema.js +25 -1
- package/src/guardian/snapshot.js +46 -2
- package/src/guardian/stability-scorer.js +169 -0
- package/src/guardian/template-command.js +184 -0
- package/src/guardian/text-formatters.js +426 -0
- package/src/guardian/verdict.js +320 -0
- package/src/guardian/verdicts.js +74 -0
- package/src/guardian/watch-runner.js +3 -7
- package/src/payments/stripe-checkout.js +169 -0
- package/src/plans/plan-definitions.js +148 -0
- package/src/plans/plan-manager.js +211 -0
- package/src/plans/usage-tracker.js +210 -0
- package/src/recipes/recipe-engine.js +188 -0
- package/src/recipes/recipe-failure-analysis.js +159 -0
- package/src/recipes/recipe-registry.js +134 -0
- package/src/recipes/recipe-runtime.js +507 -0
- package/src/recipes/recipe-store.js +410 -0
- package/guardian-contract-v1.md +0 -149
- /package/{guardian.config.json → config/guardian.config.json} +0 -0
- /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
- /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
- /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
- /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
- /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
|
-
|
|
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
|
+
};
|
package/src/guardian/smoke.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
}
|