@odavl/guardian 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +210 -210
- package/LICENSE +21 -21
- package/README.md +297 -184
- package/bin/guardian.js +2242 -2221
- package/config/README.md +59 -59
- package/config/guardian.config.json +54 -54
- package/config/guardian.policy.json +12 -12
- package/config/profiles/docs.yaml +18 -18
- package/config/profiles/ecommerce.yaml +17 -17
- package/config/profiles/landing-demo.yaml +16 -16
- package/config/profiles/marketing.yaml +18 -18
- package/config/profiles/saas.yaml +21 -21
- package/flows/example-login-flow.json +36 -36
- package/flows/example-signup-flow.json +44 -44
- package/package.json +124 -116
- package/policies/enterprise.json +12 -12
- package/policies/landing-demo.json +22 -22
- package/policies/saas.json +12 -12
- package/policies/startup.json +12 -12
- package/src/enterprise/audit-logger.js +166 -166
- package/src/enterprise/pdf-exporter.js +267 -267
- package/src/enterprise/rbac-gate.js +142 -142
- package/src/enterprise/rbac.js +239 -239
- package/src/enterprise/site-manager.js +180 -180
- package/src/founder/feedback-system.js +156 -156
- package/src/founder/founder-tracker.js +213 -213
- package/src/founder/usage-signals.js +141 -141
- package/src/guardian/action-hints.js +439 -439
- package/src/guardian/alert-ledger.js +121 -121
- package/src/guardian/artifact-sanitizer.js +56 -56
- package/src/guardian/attempt-engine.js +1069 -1029
- package/src/guardian/attempt-registry.js +267 -267
- package/src/guardian/attempt-relevance.js +106 -106
- package/src/guardian/attempt-reporter.js +513 -507
- package/src/guardian/attempt.js +274 -273
- package/src/guardian/attempts-filter.js +63 -63
- package/src/guardian/auto-attempt-builder.js +283 -283
- package/src/guardian/baseline-registry.js +177 -177
- package/src/guardian/baseline-reporter.js +143 -143
- package/src/guardian/baseline-storage.js +285 -285
- package/src/guardian/baseline.js +535 -534
- package/src/guardian/behavioral-signals.js +261 -261
- package/src/guardian/breakage-intelligence.js +224 -224
- package/src/guardian/browser-pool.js +131 -131
- package/src/guardian/browser.js +119 -119
- package/src/guardian/canonical-truth.js +308 -308
- package/src/guardian/ci-cli.js +121 -121
- package/src/guardian/ci-gate.js +96 -96
- package/src/guardian/ci-mode.js +15 -15
- package/src/guardian/ci-output.js +55 -38
- package/src/guardian/cli-summary.js +102 -102
- package/src/guardian/confidence-signals.js +251 -251
- package/src/guardian/config-loader.js +161 -161
- package/src/guardian/config-validator.js +285 -283
- package/src/guardian/coverage-model.js +239 -239
- package/src/guardian/coverage-packs.js +58 -58
- package/src/guardian/crawler.js +142 -142
- package/src/guardian/data-guardian-detector.js +189 -189
- package/src/guardian/decision-authority.js +746 -725
- package/src/guardian/detection-layers.js +271 -271
- package/src/guardian/determinism.js +146 -146
- package/src/guardian/discovery-engine.js +661 -661
- package/src/guardian/drift-detector.js +100 -100
- package/src/guardian/enhanced-html-reporter.js +522 -522
- package/src/guardian/env-guard.js +128 -127
- package/src/guardian/error-clarity.js +399 -399
- package/src/guardian/export-contract.js +196 -196
- package/src/guardian/fail-safe.js +212 -212
- package/src/guardian/failure-intelligence.js +173 -173
- package/src/guardian/failure-taxonomy.js +169 -169
- package/src/guardian/final-outcome.js +206 -206
- package/src/guardian/first-run-profile.js +89 -89
- package/src/guardian/first-run.js +65 -67
- package/src/guardian/flag-validator.js +111 -111
- package/src/guardian/flow-executor.js +641 -639
- package/src/guardian/flow-registry.js +67 -67
- package/src/guardian/honesty.js +394 -394
- package/src/guardian/html-reporter.js +416 -416
- package/src/guardian/human-intent-resolver.js +296 -296
- package/src/guardian/human-interaction-model.js +351 -351
- package/src/guardian/human-journey-context.js +184 -184
- package/src/guardian/human-navigator.js +544 -544
- package/src/guardian/human-reporter.js +435 -431
- package/src/guardian/index.js +226 -221
- package/src/guardian/init-command.js +143 -143
- package/src/guardian/intent-detector.js +148 -146
- package/src/guardian/journey-definitions.js +132 -132
- package/src/guardian/journey-scan-cli.js +142 -145
- package/src/guardian/journey-scanner.js +583 -583
- package/src/guardian/junit-reporter.js +281 -281
- package/src/guardian/language-detection.js +99 -99
- package/src/guardian/live-alert.js +56 -56
- package/src/guardian/live-baseline-compare.js +146 -146
- package/src/guardian/live-cli.js +95 -95
- package/src/guardian/live-guardian.js +210 -210
- package/src/guardian/live-scheduler-runner.js +137 -137
- package/src/guardian/live-scheduler-state.js +167 -168
- package/src/guardian/live-scheduler.js +146 -146
- package/src/guardian/live-state.js +110 -110
- package/src/guardian/market-criticality.js +335 -335
- package/src/guardian/market-reporter.js +577 -577
- package/src/guardian/network-trace.js +178 -178
- package/src/guardian/obs-logger.js +110 -110
- package/src/guardian/observed-capabilities.js +427 -427
- package/src/guardian/output-contract.js +154 -0
- package/src/guardian/output-readability.js +264 -264
- package/src/guardian/parallel-executor.js +116 -116
- package/src/guardian/path-safety.js +56 -56
- package/src/guardian/pattern-analyzer.js +348 -348
- package/src/guardian/policy.js +432 -434
- package/src/guardian/prelaunch-gate.js +193 -193
- package/src/guardian/prerequisite-checker.js +101 -101
- package/src/guardian/preset-loader.js +152 -157
- package/src/guardian/profile-loader.js +96 -96
- package/src/guardian/reality.js +3025 -2826
- package/src/guardian/realworld-scenarios.js +94 -94
- package/src/guardian/reporter.js +167 -167
- package/src/guardian/retry-policy.js +123 -123
- package/src/guardian/root-cause-analysis.js +171 -171
- package/src/guardian/rules-engine.js +558 -558
- package/src/guardian/run-artifacts.js +212 -212
- package/src/guardian/run-cleanup.js +207 -207
- package/src/guardian/run-export.js +522 -522
- package/src/guardian/run-latest.js +90 -90
- package/src/guardian/run-list.js +211 -211
- package/src/guardian/run-summary.js +20 -20
- package/src/guardian/runtime-root.js +246 -246
- package/src/guardian/safety.js +248 -248
- package/src/guardian/scan-presets.js +133 -149
- package/src/guardian/screenshot.js +152 -152
- package/src/guardian/secret-hygiene.js +44 -44
- package/src/guardian/selector-fallbacks.js +394 -394
- package/src/guardian/semantic-contact-detection.js +255 -255
- package/src/guardian/semantic-contact-finder.js +201 -201
- package/src/guardian/semantic-targets.js +234 -234
- package/src/guardian/site-intelligence.js +588 -588
- package/src/guardian/site-introspection.js +257 -257
- package/src/guardian/sitemap.js +225 -225
- package/src/guardian/smoke.js +283 -258
- package/src/guardian/snapshot-schema.js +177 -290
- package/src/guardian/snapshot.js +430 -397
- package/src/guardian/stability-scorer.js +169 -169
- package/src/guardian/success-evaluator.js +214 -214
- package/src/guardian/template-command.js +184 -184
- package/src/guardian/text-formatters.js +426 -426
- package/src/guardian/timeout-profiles.js +57 -57
- package/src/guardian/truth/attempt.contract.js +158 -0
- package/src/guardian/truth/decision.contract.js +275 -0
- package/src/guardian/truth/snapshot.contract.js +363 -0
- package/src/guardian/validators.js +323 -323
- package/src/guardian/verdict-card.js +474 -474
- package/src/guardian/verdict-clarity.js +298 -298
- package/src/guardian/verdict-policy.js +363 -363
- package/src/guardian/verdict.js +333 -333
- package/src/guardian/verdicts.js +79 -74
- package/src/guardian/visual-diff.js +247 -247
- package/src/guardian/wait-for-outcome.js +119 -119
- package/src/guardian/watch-runner.js +181 -181
- package/src/guardian/watchdog-diff.js +167 -167
- package/src/guardian/webhook.js +206 -206
- package/src/payments/stripe-checkout.js +169 -169
- package/src/plans/plan-definitions.js +148 -148
- package/src/plans/plan-manager.js +211 -211
- package/src/plans/usage-tracker.js +210 -210
- package/src/recipes/recipe-engine.js +188 -188
- package/src/recipes/recipe-failure-analysis.js +159 -159
- package/src/recipes/recipe-registry.js +134 -134
- package/src/recipes/recipe-runtime.js +507 -507
- package/src/recipes/recipe-store.js +410 -410
- package/SECURITY.md +0 -77
- package/VERSIONING.md +0 -100
- package/guardian-contract-v1.md +0 -502
|
@@ -1,394 +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
|
-
};
|
|
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
|
+
};
|