@detiq/crawler 1.1.9 → 1.1.11
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/dist/accessibility-auditor.js +54 -74
- package/dist/ai-login.js +227 -249
- package/dist/auth-token-rotator.js +57 -67
- package/dist/auth.js +299 -336
- package/dist/browser-extension.js +104 -142
- package/dist/captcha-solver.js +98 -125
- package/dist/color-contrast.js +77 -89
- package/dist/content-chunker.js +126 -126
- package/dist/content-dedup.js +54 -71
- package/dist/cookie-consent.js +68 -83
- package/dist/cookie-jar.js +53 -60
- package/dist/crawler.js +4494 -5353
- package/dist/custom-js-injector.js +45 -61
- package/dist/dark-mode-capture.js +67 -81
- package/dist/dead-link-detector.js +47 -55
- package/dist/element-hash.js +8 -12
- package/dist/email-otp.js +154 -195
- package/dist/engine-router.js +87 -98
- package/dist/export/bulk-export.js +70 -55
- package/dist/export/vector-export.js +77 -105
- package/dist/extractors/advanced.js +249 -266
- package/dist/extractors/artifact-envelope.js +59 -58
- package/dist/failure-analysis-agent.js +37 -33
- package/dist/feature-flag-detector.js +51 -66
- package/dist/form-detector.js +55 -75
- package/dist/frontier.js +76 -101
- package/dist/gap-pass.js +68 -89
- package/dist/graphql-detector.js +62 -72
- package/dist/http-engine.js +282 -330
- package/dist/incremental-diff.js +54 -87
- package/dist/index.js +67 -28
- package/dist/input-fuzzer.js +93 -97
- package/dist/interactive-probe.js +715 -827
- package/dist/job-queue.js +2151 -2169
- package/dist/js-coverage.js +52 -56
- package/dist/js-router-detector.js +86 -117
- package/dist/language-detector.js +51 -63
- package/dist/link-classifier.js +32 -50
- package/dist/live-view.js +91 -108
- package/dist/locator-extractor.js +151 -187
- package/dist/logger.js +9 -7
- package/dist/markdown-extract.js +48 -61
- package/dist/native-crawler.js +780 -946
- package/dist/network-observer.js +135 -156
- package/dist/network-throttle.js +29 -40
- package/dist/openapi-generator.js +77 -92
- package/dist/pdf-extractor.js +72 -90
- package/dist/performance-metrics.js +80 -102
- package/dist/phash.js +125 -144
- package/dist/pii-scrubber.js +65 -68
- package/dist/playwright-recorder.js +70 -58
- package/dist/policies/crawl-safety.js +31 -34
- package/dist/profiles/domain-profile.js +130 -125
- package/dist/proxy.js +146 -172
- package/dist/rate-limiter.js +58 -65
- package/dist/redirect-tracker.js +60 -64
- package/dist/redis-frontier.js +116 -136
- package/dist/redis-queue.js +157 -144
- package/dist/response-cache.js +127 -140
- package/dist/responsive-detector.js +42 -46
- package/dist/retry-policy.js +46 -50
- package/dist/robots-txt.js +69 -83
- package/dist/screen-reader.js +45 -70
- package/dist/screenshot-masker.js +59 -77
- package/dist/seo-checker.js +88 -107
- package/dist/session-capture.js +192 -212
- package/dist/session-export.js +107 -133
- package/dist/sitemap-writer.js +38 -41
- package/dist/sitemap.js +141 -202
- package/dist/skyvern.js +123 -148
- package/dist/sqs-queue.js +42 -42
- package/dist/sso-token.js +74 -90
- package/dist/stealth/human-behavior.js +156 -173
- package/dist/telemetry.js +68 -82
- package/dist/test-executor.js +309 -335
- package/dist/tls-fingerprint.js +77 -88
- package/dist/url-pattern-completer.js +71 -90
- package/dist/visual-diff-verdict.js +23 -38
- package/dist/webhook-notifier.js +71 -69
- package/dist/websocket-detector.js +56 -68
- package/dist/worker.js +29 -38
- package/package.json +1 -1
- package/src/job-queue.ts +136 -0
|
@@ -1,886 +1,774 @@
|
|
|
1
|
-
|
|
2
|
-
* Interactive Probe — Phase 2 Dynamic Observation
|
|
3
|
-
*
|
|
4
|
-
* The static crawl captures DOM structure at rest. This module runs AFTER the static crawl
|
|
5
|
-
* to observe dynamic behaviour by actually interacting with the UI:
|
|
6
|
-
*
|
|
7
|
-
* 1. Empty submit → captures required-field validation error messages
|
|
8
|
-
* 2. Invalid format → captures format validation errors (bad email, short password)
|
|
9
|
-
* 3. Button clicks → captures modal/dialog contents
|
|
10
|
-
* 4. Select changes → captures conditional reveals (show/hide logic)
|
|
11
|
-
* 5. Success paths → captures toast/banner messages (where input is safe)
|
|
12
|
-
*
|
|
13
|
-
* Results are stored as KnowledgeChunks (sourceType='probe') and update the
|
|
14
|
-
* ERROR_CATALOGUE + INTERACTION_PATTERNS skill files with real observed data
|
|
15
|
-
* instead of LLM inference.
|
|
16
|
-
*
|
|
17
|
-
* Call signature: probeScreen(page, screenId, elements, opts)
|
|
18
|
-
* Called from crawlProject() after static element extraction when opts.interactive=true.
|
|
19
|
-
*/
|
|
20
|
-
import { AppBrain } from '@detiq/app-brain';
|
|
21
|
-
// ── CSS selectors that reliably find error messages across frameworks ────────
|
|
1
|
+
import { AppBrain } from "@detiq/app-brain";
|
|
22
2
|
const ERROR_SELECTORS = [
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
3
|
+
'[role="alert"]',
|
|
4
|
+
'[aria-live="assertive"]',
|
|
5
|
+
'[aria-live="polite"]',
|
|
6
|
+
".invalid-feedback",
|
|
7
|
+
".field-error",
|
|
8
|
+
".error-message",
|
|
9
|
+
".form-error",
|
|
10
|
+
".validation-error",
|
|
11
|
+
'[class*="error" i]',
|
|
12
|
+
'[class*="invalid" i]',
|
|
13
|
+
'[class*="Error"]',
|
|
14
|
+
"p.text-red-500",
|
|
15
|
+
"p.text-red-600",
|
|
16
|
+
"p.text-destructive",
|
|
17
|
+
"p.text-danger",
|
|
18
|
+
"[data-error]",
|
|
19
|
+
"[data-invalid]",
|
|
20
|
+
".ant-form-item-explain-error",
|
|
21
|
+
// Ant Design
|
|
22
|
+
".el-form-item__error",
|
|
23
|
+
// Element UI
|
|
24
|
+
".chakra-form__error-message",
|
|
25
|
+
// Chakra UI
|
|
26
|
+
".mantine-InputWrapper-error"
|
|
27
|
+
// Mantine
|
|
28
|
+
].join(", ");
|
|
42
29
|
const TOAST_SELECTORS = [
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
].join(
|
|
56
|
-
/** Wait up to `maxMs` for any selector to appear, then return its text content. */
|
|
30
|
+
'[role="status"]',
|
|
31
|
+
"[data-sonner-toast]",
|
|
32
|
+
"[data-toast]",
|
|
33
|
+
"[data-hot-toast]",
|
|
34
|
+
".Toastify__toast",
|
|
35
|
+
".toast-message",
|
|
36
|
+
".notification-body",
|
|
37
|
+
".notistack-snackbar",
|
|
38
|
+
'[class*="toast" i]',
|
|
39
|
+
'[class*="Toast"]',
|
|
40
|
+
'[class*="snackbar" i]',
|
|
41
|
+
'[class*="notification" i]'
|
|
42
|
+
].join(", ");
|
|
57
43
|
async function waitForAny(page, selector, maxMs = 2500) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
44
|
+
try {
|
|
45
|
+
await page.waitForSelector(selector, { timeout: maxMs, state: "visible" });
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return page.$$eval(
|
|
50
|
+
selector,
|
|
51
|
+
(els) => els.map((el) => el.innerText?.replace(/\s+/g, " ").trim()).filter(Boolean)
|
|
52
|
+
);
|
|
65
53
|
}
|
|
66
|
-
/** Collect all currently-visible error messages. */
|
|
67
54
|
async function collectErrors(page) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
55
|
+
try {
|
|
56
|
+
return page.$$eval(
|
|
57
|
+
ERROR_SELECTORS,
|
|
58
|
+
(els) => els.filter((el) => {
|
|
59
|
+
const style = window.getComputedStyle(el);
|
|
60
|
+
return style.display !== "none" && style.visibility !== "hidden" && el.innerText?.trim().length > 0;
|
|
61
|
+
}).map((el) => el.innerText?.replace(/\s+/g, " ").trim()).filter(Boolean)
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
80
66
|
}
|
|
81
|
-
/** Collect any visible toast / notification content. */
|
|
82
67
|
async function collectToasts(page) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
68
|
+
try {
|
|
69
|
+
return page.$$eval(
|
|
70
|
+
TOAST_SELECTORS,
|
|
71
|
+
(els) => els.filter((el) => {
|
|
72
|
+
const style = window.getComputedStyle(el);
|
|
73
|
+
return style.display !== "none" && el.innerText?.trim().length > 0;
|
|
74
|
+
}).map((el) => el.innerText?.replace(/\s+/g, " ").trim()).filter(Boolean)
|
|
75
|
+
);
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
95
79
|
}
|
|
96
|
-
/** Find the primary submit button on the page. */
|
|
97
80
|
async function findSubmitButton(page) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return SUBMIT_KW.test(text);
|
|
107
|
-
});
|
|
108
|
-
if (!primary)
|
|
109
|
-
return null;
|
|
110
|
-
return primary.getAttribute('data-testid') || primary.getAttribute('aria-label') || primary.innerText?.trim().slice(0, 60);
|
|
81
|
+
return page.evaluate(() => {
|
|
82
|
+
const SUBMIT_KW = /submit|send|save|create|confirm|continue|proceed|register|sign.?in|log.?in|apply|verify|search|next|finish|complete|order|buy|checkout/i;
|
|
83
|
+
const candidates = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"]'));
|
|
84
|
+
const primary = candidates.find((el) => {
|
|
85
|
+
const style = window.getComputedStyle(el);
|
|
86
|
+
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
87
|
+
const text = el.innerText || el.value || el.getAttribute("aria-label") || "";
|
|
88
|
+
return SUBMIT_KW.test(text);
|
|
111
89
|
});
|
|
90
|
+
if (!primary) return null;
|
|
91
|
+
return primary.getAttribute("data-testid") || primary.getAttribute("aria-label") || primary.innerText?.trim().slice(0, 60);
|
|
92
|
+
});
|
|
112
93
|
}
|
|
113
|
-
/** Find all modal-trigger buttons (non-submit buttons). */
|
|
114
94
|
async function findModalTriggers(page) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
return true;
|
|
134
|
-
})
|
|
135
|
-
.slice(0, 20) // probe at most 20 buttons per screen
|
|
136
|
-
.map((el) => {
|
|
137
|
-
const label = el.innerText?.trim() || el.getAttribute('aria-label') || 'button';
|
|
138
|
-
return { selector: `[aria-label="${label}"]`, label: label.slice(0, 60) };
|
|
139
|
-
});
|
|
95
|
+
return page.evaluate(() => {
|
|
96
|
+
const SKIP = /submit|send|save|create|log.?in|sign.?in|register|continue|checkout|buy|order|search|close|cancel|dismiss/i;
|
|
97
|
+
const NAV_PARENTS = /* @__PURE__ */ new Set(["NAV", "HEADER", "FOOTER"]);
|
|
98
|
+
return Array.from(document.querySelectorAll('button, [role="button"]')).filter((el) => {
|
|
99
|
+
const style = window.getComputedStyle(el);
|
|
100
|
+
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
101
|
+
const label = el.innerText?.trim() || el.getAttribute("aria-label") || "";
|
|
102
|
+
if (!label || SKIP.test(label)) return false;
|
|
103
|
+
let p = el.parentElement;
|
|
104
|
+
while (p) {
|
|
105
|
+
if (NAV_PARENTS.has(p.tagName)) return false;
|
|
106
|
+
p = p.parentElement;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}).slice(0, 20).map((el) => {
|
|
110
|
+
const label = el.innerText?.trim() || el.getAttribute("aria-label") || "button";
|
|
111
|
+
return { selector: `[aria-label="${label}"]`, label: label.slice(0, 60) };
|
|
140
112
|
});
|
|
113
|
+
});
|
|
141
114
|
}
|
|
142
|
-
/** Try to interact with select elements and observe conditional reveals. */
|
|
143
115
|
async function probeConditionals(page) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
.filter((t) => t && t.length > 10)
|
|
164
|
-
.slice(0, 3)
|
|
165
|
-
.join(' | ');
|
|
166
|
-
});
|
|
167
|
-
if (revealed) {
|
|
168
|
-
results.push({ triggerElement: label, triggerValue: opt.text || opt.value, revealedText: revealed.slice(0, 200) });
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
116
|
+
const results = [];
|
|
117
|
+
try {
|
|
118
|
+
const selects = await page.$$("select");
|
|
119
|
+
for (const sel of selects.slice(0, 5)) {
|
|
120
|
+
const label = await sel.getAttribute("aria-label") || await sel.getAttribute("name") || "select";
|
|
121
|
+
const options = await sel.$$eval("option", (opts) => opts.slice(1, 3).map((o) => ({ value: o.value, text: o.textContent?.trim() })));
|
|
122
|
+
for (const opt of options) {
|
|
123
|
+
if (!opt.value) continue;
|
|
124
|
+
const domBefore = await page.evaluate(() => document.body.innerText.length);
|
|
125
|
+
await sel.selectOption(opt.value);
|
|
126
|
+
await page.waitForTimeout(500);
|
|
127
|
+
const domAfter = await page.evaluate(() => document.body.innerText.length);
|
|
128
|
+
if (Math.abs(domAfter - domBefore) > 50) {
|
|
129
|
+
const revealed = await page.evaluate(() => {
|
|
130
|
+
return Array.from(document.querySelectorAll('[data-conditional],[data-show],[class*="conditional"]')).filter((el) => window.getComputedStyle(el).display !== "none").map((el) => el.innerText?.trim()).filter((t) => t && t.length > 10).slice(0, 3).join(" | ");
|
|
131
|
+
});
|
|
132
|
+
if (revealed) {
|
|
133
|
+
results.push({ triggerElement: label, triggerValue: opt.text || opt.value, revealedText: revealed.slice(0, 200) });
|
|
134
|
+
}
|
|
172
135
|
}
|
|
136
|
+
}
|
|
173
137
|
}
|
|
174
|
-
|
|
175
|
-
|
|
138
|
+
} catch {
|
|
139
|
+
}
|
|
140
|
+
return results;
|
|
176
141
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
// Fallback: text-proximity match for errors not covered by aria-invalid
|
|
212
|
-
for (const errorText of errors.filter((e) => !ariaMatched.has(e))) {
|
|
213
|
-
const matchedField = formInputs.find((el) => errorText.toLowerCase().includes(el.meaning.toLowerCase().split(' ')[0]) ||
|
|
214
|
-
el.meaning.toLowerCase().includes(errorText.toLowerCase().split(' ')[0]));
|
|
215
|
-
result.validationErrors.push({
|
|
216
|
-
field: matchedField?.meaning ?? 'unknown',
|
|
217
|
-
trigger: 'empty_submit',
|
|
218
|
-
errorText: errorText.slice(0, 150),
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
// Check for loading state text during submit
|
|
222
|
-
const loadingText = await page.evaluate(() => {
|
|
223
|
-
const loading = Array.from(document.querySelectorAll('button, [aria-busy="true"], [class*="loading" i], [class*="spinner" i]'))
|
|
224
|
-
.map((el) => el.innerText?.trim())
|
|
225
|
-
.filter((t) => t && /loading|saving|submitting|sending|processing/i.test(t));
|
|
226
|
-
return loading.slice(0, 3);
|
|
227
|
-
});
|
|
228
|
-
result.loadingPatterns.push(...loadingText);
|
|
229
|
-
}
|
|
142
|
+
async function probeScreen(page, screenId, url, elements) {
|
|
143
|
+
const result = {
|
|
144
|
+
screenId,
|
|
145
|
+
url,
|
|
146
|
+
validationErrors: [],
|
|
147
|
+
successMessages: [],
|
|
148
|
+
modals: [],
|
|
149
|
+
conditionals: [],
|
|
150
|
+
loadingPatterns: []
|
|
151
|
+
};
|
|
152
|
+
const formInputs = elements.filter(
|
|
153
|
+
(e) => /input|text|email|password|tel|search|textarea|select/i.test(e.role)
|
|
154
|
+
);
|
|
155
|
+
const hasForm = formInputs.length > 0;
|
|
156
|
+
if (hasForm) {
|
|
157
|
+
try {
|
|
158
|
+
const submitLabel = await findSubmitButton(page);
|
|
159
|
+
if (submitLabel) {
|
|
160
|
+
const submitBtn = page.locator(`button:has-text("${submitLabel}")`).first();
|
|
161
|
+
await submitBtn.click({ timeout: 2e3 });
|
|
162
|
+
await page.waitForTimeout(1500);
|
|
163
|
+
const errors = await collectErrors(page);
|
|
164
|
+
const ariaFieldErrors = await page.evaluate(
|
|
165
|
+
() => Array.from(document.querySelectorAll('[aria-invalid="true"]')).map((el) => {
|
|
166
|
+
const describedBy = el.getAttribute("aria-describedby");
|
|
167
|
+
const errorEl = describedBy ? document.getElementById(describedBy) : null;
|
|
168
|
+
const label = el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.name || "";
|
|
169
|
+
return { label, errorText: errorEl?.textContent?.trim() || "" };
|
|
170
|
+
}).filter((f) => f.errorText)
|
|
171
|
+
);
|
|
172
|
+
const ariaMatched = /* @__PURE__ */ new Set();
|
|
173
|
+
for (const af of ariaFieldErrors) {
|
|
174
|
+
ariaMatched.add(af.errorText);
|
|
175
|
+
result.validationErrors.push({ field: af.label || "unknown", trigger: "empty_submit", errorText: af.errorText.slice(0, 150) });
|
|
230
176
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
try {
|
|
241
|
-
await emailLocator.fill('notanemail', { timeout: 1000 });
|
|
242
|
-
await emailLocator.blur();
|
|
243
|
-
await page.waitForTimeout(600);
|
|
244
|
-
const errors = await collectErrors(page);
|
|
245
|
-
for (const errorText of errors) {
|
|
246
|
-
result.validationErrors.push({ field: emailField.meaning, trigger: 'invalid_format', errorText: errorText.slice(0, 150) });
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
catch { /* locator not found */ }
|
|
250
|
-
}
|
|
251
|
-
if (passwordField) {
|
|
252
|
-
const pwLocator = page.locator('input[type="password"]').first();
|
|
253
|
-
try {
|
|
254
|
-
await pwLocator.fill('123', { timeout: 1000 });
|
|
255
|
-
await pwLocator.blur();
|
|
256
|
-
await page.waitForTimeout(600);
|
|
257
|
-
const errors = await collectErrors(page);
|
|
258
|
-
for (const errorText of errors) {
|
|
259
|
-
if (!result.validationErrors.some((e) => e.errorText === errorText)) {
|
|
260
|
-
result.validationErrors.push({ field: passwordField.meaning, trigger: 'invalid_format', errorText: errorText.slice(0, 150) });
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
catch { /* locator not found */ }
|
|
265
|
-
}
|
|
177
|
+
for (const errorText of errors.filter((e) => !ariaMatched.has(e))) {
|
|
178
|
+
const matchedField = formInputs.find(
|
|
179
|
+
(el) => errorText.toLowerCase().includes(el.meaning.toLowerCase().split(" ")[0]) || el.meaning.toLowerCase().includes(errorText.toLowerCase().split(" ")[0])
|
|
180
|
+
);
|
|
181
|
+
result.validationErrors.push({
|
|
182
|
+
field: matchedField?.meaning ?? "unknown",
|
|
183
|
+
trigger: "empty_submit",
|
|
184
|
+
errorText: errorText.slice(0, 150)
|
|
185
|
+
});
|
|
266
186
|
}
|
|
267
|
-
|
|
187
|
+
const loadingText = await page.evaluate(() => {
|
|
188
|
+
const loading = Array.from(document.querySelectorAll('button, [aria-busy="true"], [class*="loading" i], [class*="spinner" i]')).map((el) => el.innerText?.trim()).filter((t) => t && /loading|saving|submitting|sending|processing/i.test(t));
|
|
189
|
+
return loading.slice(0, 3);
|
|
190
|
+
});
|
|
191
|
+
result.loadingPatterns.push(...loadingText);
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
268
194
|
}
|
|
269
|
-
|
|
195
|
+
}
|
|
196
|
+
if (hasForm) {
|
|
270
197
|
try {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
title: dialogText.title,
|
|
297
|
-
content: dialogText.content,
|
|
298
|
-
fields: dialogText.fields.slice(0, 10),
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
// No modal appeared — check for accordion/disclosure panel reveals
|
|
303
|
-
// Clicks on accordion triggers change aria-expanded but don't open a dialog.
|
|
304
|
-
const accordionText = await page.evaluate(() => {
|
|
305
|
-
const panels = Array.from(document.querySelectorAll('[aria-expanded="true"] + *, details[open] > :not(summary), [data-state="open"]:not([role="dialog"]):not([class*="modal" i]), [data-headlessui-state="open"] > *')).filter((el) => {
|
|
306
|
-
const s = window.getComputedStyle(el);
|
|
307
|
-
return s.display !== 'none' && s.visibility !== 'hidden';
|
|
308
|
-
});
|
|
309
|
-
if (panels.length === 0)
|
|
310
|
-
return null;
|
|
311
|
-
const panel = panels[0];
|
|
312
|
-
const heading = panel.querySelector('h2,h3,h4')?.textContent?.replace(/\s+/g, ' ').trim() || '';
|
|
313
|
-
const content = panel.innerText?.replace(/\s+/g, ' ').trim().slice(0, 400) || '';
|
|
314
|
-
return content.length > 15 ? { heading, content } : null;
|
|
315
|
-
});
|
|
316
|
-
if (accordionText) {
|
|
317
|
-
result.modals.push({
|
|
318
|
-
triggerElement: trigger.label,
|
|
319
|
-
title: accordionText.heading || `${trigger.label} (revealed)`,
|
|
320
|
-
content: accordionText.content,
|
|
321
|
-
fields: [],
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
// Dismiss: Escape key first, then close button
|
|
326
|
-
await page.keyboard.press('Escape');
|
|
327
|
-
await page.waitForTimeout(400);
|
|
328
|
-
// Check if toast appeared after the click
|
|
329
|
-
const toasts = await collectToasts(page);
|
|
330
|
-
for (const t of toasts) {
|
|
331
|
-
if (t.length > 5)
|
|
332
|
-
result.successMessages.push({ type: 'toast', content: t.slice(0, 200) });
|
|
333
|
-
}
|
|
198
|
+
const emailField = formInputs.find((e) => e.expectedData === "valid_email" || /email/i.test(e.meaning));
|
|
199
|
+
const passwordField = formInputs.find((e) => e.expectedData === "password" || /password/i.test(e.meaning));
|
|
200
|
+
if (emailField) {
|
|
201
|
+
const emailLocator = page.locator('input[type="email"], input[name*="email"], input[placeholder*="email" i]').first();
|
|
202
|
+
try {
|
|
203
|
+
await emailLocator.fill("notanemail", { timeout: 1e3 });
|
|
204
|
+
await emailLocator.blur();
|
|
205
|
+
await page.waitForTimeout(600);
|
|
206
|
+
const errors = await collectErrors(page);
|
|
207
|
+
for (const errorText of errors) {
|
|
208
|
+
result.validationErrors.push({ field: emailField.meaning, trigger: "invalid_format", errorText: errorText.slice(0, 150) });
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (passwordField) {
|
|
214
|
+
const pwLocator = page.locator('input[type="password"]').first();
|
|
215
|
+
try {
|
|
216
|
+
await pwLocator.fill("123", { timeout: 1e3 });
|
|
217
|
+
await pwLocator.blur();
|
|
218
|
+
await page.waitForTimeout(600);
|
|
219
|
+
const errors = await collectErrors(page);
|
|
220
|
+
for (const errorText of errors) {
|
|
221
|
+
if (!result.validationErrors.some((e) => e.errorText === errorText)) {
|
|
222
|
+
result.validationErrors.push({ field: passwordField.meaning, trigger: "invalid_format", errorText: errorText.slice(0, 150) });
|
|
334
223
|
}
|
|
335
|
-
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
336
226
|
}
|
|
337
|
-
|
|
338
|
-
catch {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const triggers = await findModalTriggers(page);
|
|
233
|
+
for (const trigger of triggers) {
|
|
234
|
+
try {
|
|
235
|
+
const btn = page.locator(`button:has-text("${trigger.label}")`).first();
|
|
236
|
+
const domBefore = await page.evaluate(() => document.body.innerText.length);
|
|
237
|
+
await btn.click({ timeout: 1500 });
|
|
238
|
+
await page.waitForTimeout(1e3);
|
|
239
|
+
const dialogText = await page.evaluate(() => {
|
|
240
|
+
const DIALOG_SEL = '[role="dialog"], [role="alertdialog"], .modal, .Modal, [class*="modal" i], [class*="dialog" i], [class*="Dialog"]';
|
|
241
|
+
const dialogs = Array.from(document.querySelectorAll(DIALOG_SEL)).filter((el) => window.getComputedStyle(el).display !== "none");
|
|
242
|
+
if (dialogs.length === 0) return null;
|
|
243
|
+
const d = dialogs[0];
|
|
244
|
+
const title = d.querySelector('[role="heading"], h1, h2, h3')?.textContent?.trim() || "";
|
|
245
|
+
const content = d.innerText?.replace(/\s+/g, " ").trim().slice(0, 400);
|
|
246
|
+
const fields = Array.from(d.querySelectorAll("input, select, textarea, button")).map((f) => f.getAttribute("aria-label") || f.getAttribute("placeholder") || f.value || "").filter((t) => t.trim().length > 0);
|
|
247
|
+
return { title, content, fields };
|
|
248
|
+
});
|
|
249
|
+
if (dialogText && dialogText.content.length > 20) {
|
|
250
|
+
result.modals.push({
|
|
251
|
+
triggerElement: trigger.label,
|
|
252
|
+
title: dialogText.title,
|
|
253
|
+
content: dialogText.content,
|
|
254
|
+
fields: dialogText.fields.slice(0, 10)
|
|
255
|
+
});
|
|
256
|
+
} else {
|
|
257
|
+
const accordionText = await page.evaluate(() => {
|
|
258
|
+
const panels = Array.from(document.querySelectorAll(
|
|
259
|
+
'[aria-expanded="true"] + *, details[open] > :not(summary), [data-state="open"]:not([role="dialog"]):not([class*="modal" i]), [data-headlessui-state="open"] > *'
|
|
260
|
+
)).filter((el) => {
|
|
261
|
+
const s = window.getComputedStyle(el);
|
|
262
|
+
return s.display !== "none" && s.visibility !== "hidden";
|
|
263
|
+
});
|
|
264
|
+
if (panels.length === 0) return null;
|
|
265
|
+
const panel = panels[0];
|
|
266
|
+
const heading = panel.querySelector("h2,h3,h4")?.textContent?.replace(/\s+/g, " ").trim() || "";
|
|
267
|
+
const content = panel.innerText?.replace(/\s+/g, " ").trim().slice(0, 400) || "";
|
|
268
|
+
return content.length > 15 ? { heading, content } : null;
|
|
269
|
+
});
|
|
270
|
+
if (accordionText) {
|
|
271
|
+
result.modals.push({
|
|
272
|
+
triggerElement: trigger.label,
|
|
273
|
+
title: accordionText.heading || `${trigger.label} (revealed)`,
|
|
274
|
+
content: accordionText.content,
|
|
275
|
+
fields: []
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
await page.keyboard.press("Escape");
|
|
280
|
+
await page.waitForTimeout(400);
|
|
281
|
+
const toasts = await collectToasts(page);
|
|
282
|
+
for (const t of toasts) {
|
|
283
|
+
if (t.length > 5) result.successMessages.push({ type: "toast", content: t.slice(0, 200) });
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const conditionals = await probeConditionals(page);
|
|
292
|
+
result.conditionals.push(...conditionals);
|
|
293
|
+
} catch {
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
result.tabPanels = await probeTabPanels(page);
|
|
297
|
+
} catch {
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
result.tooltips = await probeTooltips(page);
|
|
301
|
+
} catch {
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
result.carouselSlides = await probeCarousels(page);
|
|
305
|
+
} catch {
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
result.wizardSteps = await probeWizardSteps(page);
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
result.focusTraps = await detectFocusTraps(page);
|
|
313
|
+
} catch {
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
result.skipNavIssues = await probeSkipNavigation(page);
|
|
317
|
+
} catch {
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
result.contextMenus = await probeContextMenus(page);
|
|
321
|
+
} catch {
|
|
322
|
+
}
|
|
323
|
+
return result;
|
|
381
324
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
325
|
+
function probeResultToObservations(result) {
|
|
326
|
+
const obs = [];
|
|
327
|
+
for (const ve of result.validationErrors) {
|
|
328
|
+
obs.push({ elementMeaning: ve.field, observationType: "VALIDATION_ERROR", observedText: ve.errorText, triggeredBy: ve.trigger });
|
|
329
|
+
}
|
|
330
|
+
for (const sm of result.successMessages) {
|
|
331
|
+
obs.push({ elementMeaning: "(page)", observationType: "SUCCESS_TOAST", observedText: sm.content, triggeredBy: sm.type });
|
|
332
|
+
}
|
|
333
|
+
for (const m of result.modals) {
|
|
334
|
+
obs.push({ elementMeaning: m.triggerElement, observationType: "MODAL", observedText: [m.title, m.content].filter(Boolean).join(" \u2014 ").slice(0, 500), triggeredBy: `button_click:${m.triggerElement}` });
|
|
335
|
+
}
|
|
336
|
+
for (const c of result.conditionals) {
|
|
337
|
+
obs.push({ elementMeaning: c.triggerElement, observationType: "CONDITIONAL_REVEAL", observedText: c.revealedText, triggeredBy: `select_change:${c.triggerElement}:${c.triggerValue}` });
|
|
338
|
+
}
|
|
339
|
+
for (const lp of result.loadingPatterns) {
|
|
340
|
+
obs.push({ elementMeaning: "(submit button)", observationType: "LOADING_STATE", observedText: lp, triggeredBy: "form_submit" });
|
|
341
|
+
}
|
|
342
|
+
for (const tp of result.tabPanels ?? []) {
|
|
343
|
+
obs.push({ elementMeaning: tp.tabLabel, observationType: "TAB_PANEL", observedText: tp.panelContent, triggeredBy: `tab_click:${tp.tabLabel}` });
|
|
344
|
+
}
|
|
345
|
+
for (const tt of result.tooltips ?? []) {
|
|
346
|
+
obs.push({ elementMeaning: tt.triggerLabel, observationType: "TOOLTIP", observedText: tt.tooltipText, triggeredBy: "hover_or_title" });
|
|
347
|
+
}
|
|
348
|
+
for (const cs of result.carouselSlides ?? []) {
|
|
349
|
+
obs.push({ elementMeaning: `Slide ${cs.slideIndex + 1}`, observationType: "CAROUSEL_SLIDE", observedText: cs.content, triggeredBy: `carousel_next:${cs.slideIndex}` });
|
|
350
|
+
}
|
|
351
|
+
for (const ws of result.wizardSteps ?? []) {
|
|
352
|
+
obs.push({ elementMeaning: ws.stepLabel ?? `Step ${ws.stepIndex + 1}`, observationType: "WIZARD_STEP", observedText: ws.content, triggeredBy: `wizard_next:${ws.stepIndex}` });
|
|
353
|
+
}
|
|
354
|
+
for (const ft of result.focusTraps ?? []) {
|
|
355
|
+
obs.push({ elementMeaning: ft.modalTrigger, observationType: "FOCUS_TRAP", observedText: ft.focusTrapped ? "Focus correctly trapped inside dialog" : "FAIL: focus escaped dialog \u2014 WCAG 2.1.2 violation", triggeredBy: "tab_key_sequence" });
|
|
356
|
+
}
|
|
357
|
+
for (const issue of result.skipNavIssues ?? []) {
|
|
358
|
+
obs.push({ elementMeaning: "(page)", observationType: "SKIP_NAV_ISSUE", observedText: issue, triggeredBy: "skip_nav_audit" });
|
|
359
|
+
}
|
|
360
|
+
for (const cm of result.contextMenus ?? []) {
|
|
361
|
+
obs.push({ elementMeaning: cm.trigger, observationType: "CONTEXT_MENU", observedText: cm.items.join(" | "), triggeredBy: "right_click" });
|
|
362
|
+
}
|
|
363
|
+
return obs;
|
|
364
|
+
}
|
|
365
|
+
function probeResultToMarkdown(result) {
|
|
366
|
+
const lines = [`## Probe Observations \u2014 ${result.url}
|
|
367
|
+
`];
|
|
368
|
+
if (result.validationErrors.length > 0) {
|
|
369
|
+
lines.push("### Validation Errors (observed)");
|
|
385
370
|
for (const ve of result.validationErrors) {
|
|
386
|
-
|
|
371
|
+
lines.push(`- **${ve.field}** (${ve.trigger}): "${ve.errorText}"`);
|
|
387
372
|
}
|
|
373
|
+
}
|
|
374
|
+
if (result.successMessages.length > 0) {
|
|
375
|
+
lines.push("\n### Success / Feedback Messages (observed)");
|
|
388
376
|
for (const sm of result.successMessages) {
|
|
389
|
-
|
|
377
|
+
lines.push(`- ${sm.type}: "${sm.content}"`);
|
|
390
378
|
}
|
|
379
|
+
}
|
|
380
|
+
if (result.modals.length > 0) {
|
|
381
|
+
lines.push("\n### Modal / Dialog Contents (observed)");
|
|
391
382
|
for (const m of result.modals) {
|
|
392
|
-
|
|
393
|
-
|
|
383
|
+
lines.push(`- **Trigger:** ${m.triggerElement}`);
|
|
384
|
+
if (m.title) lines.push(` - Title: ${m.title}`);
|
|
385
|
+
lines.push(` - Content: ${m.content.slice(0, 200)}`);
|
|
386
|
+
if (m.fields.length > 0) lines.push(` - Fields: ${m.fields.join(", ")}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if ((result.tabPanels ?? []).length > 0) {
|
|
390
|
+
lines.push("\n### Tab Panel Contents (observed)");
|
|
391
|
+
for (const tp of result.tabPanels) {
|
|
392
|
+
lines.push(`- **Tab: ${tp.tabLabel}**${tp.panelHeading ? ` \u2014 ${tp.panelHeading}` : ""}: ${tp.panelContent.slice(0, 200)}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if ((result.tooltips ?? []).length > 0) {
|
|
396
|
+
lines.push("\n### Tooltips (observed)");
|
|
397
|
+
for (const tt of result.tooltips) {
|
|
398
|
+
lines.push(`- **${tt.triggerLabel}**: ${tt.tooltipText}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if ((result.carouselSlides ?? []).length > 0) {
|
|
402
|
+
lines.push("\n### Carousel Slides (observed)");
|
|
403
|
+
for (const cs of result.carouselSlides) {
|
|
404
|
+
lines.push(`- **Slide ${cs.slideIndex + 1}**${cs.heading ? ` \u2014 ${cs.heading}` : ""}: ${cs.content.slice(0, 200)}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if ((result.wizardSteps ?? []).length > 0) {
|
|
408
|
+
lines.push("\n### Wizard / Multi-Step Form (observed)");
|
|
409
|
+
for (const ws of result.wizardSteps) {
|
|
410
|
+
const label = ws.stepLabel ? ` \u2014 ${ws.stepLabel}` : "";
|
|
411
|
+
lines.push(`- **Step ${ws.stepIndex + 1}${label}**: ${ws.content.slice(0, 200)}`);
|
|
412
|
+
if (ws.fields.length > 0) lines.push(` - Fields: ${ws.fields.join(", ")}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if ((result.focusTraps ?? []).length > 0) {
|
|
416
|
+
lines.push("\n### Focus-Trap Audit (observed)");
|
|
417
|
+
for (const ft of result.focusTraps) {
|
|
418
|
+
const verdict = ft.focusTrapped ? "\u2705 PASS" : "\u274C FAIL (WCAG 2.1.2)";
|
|
419
|
+
lines.push(`- **${ft.modalTrigger}**: ${verdict} \u2014 focused: [${ft.sampledFocused.slice(0, 4).join(", ")}]`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (result.conditionals.length > 0) {
|
|
423
|
+
lines.push("\n### Conditional UI (observed)");
|
|
394
424
|
for (const c of result.conditionals) {
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
for (const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
for (const cm of result.contextMenus ?? []) {
|
|
419
|
-
obs.push({ elementMeaning: cm.trigger, observationType: 'CONTEXT_MENU', observedText: cm.items.join(' | '), triggeredBy: 'right_click' });
|
|
420
|
-
}
|
|
421
|
-
return obs;
|
|
425
|
+
lines.push(`- **${c.triggerElement}** = "${c.triggerValue}" reveals: ${c.revealedText}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (result.loadingPatterns.length > 0) {
|
|
429
|
+
lines.push("\n### Loading State Text (observed)");
|
|
430
|
+
for (const lp of result.loadingPatterns) lines.push(`- "${lp}"`);
|
|
431
|
+
}
|
|
432
|
+
const full = lines.join("\n");
|
|
433
|
+
const errorSection = result.validationErrors.length > 0 ? `## Real Validation Errors \u2014 ${result.url}
|
|
434
|
+
` + result.validationErrors.map((e) => `- **${e.field}**: "${e.errorText}"`).join("\n") : "";
|
|
435
|
+
const interactionSection = [
|
|
436
|
+
result.successMessages.length > 0 ? `## Success Messages \u2014 ${result.url}
|
|
437
|
+
` + result.successMessages.map((s) => `- ${s.type}: "${s.content}"`).join("\n") : "",
|
|
438
|
+
result.loadingPatterns.length > 0 ? `## Loading Patterns \u2014 ${result.url}
|
|
439
|
+
` + result.loadingPatterns.map((lp) => `- "${lp}"`).join("\n") : "",
|
|
440
|
+
result.modals.length > 0 ? `## Modal Contents \u2014 ${result.url}
|
|
441
|
+
` + result.modals.map((m) => `### ${m.triggerElement}
|
|
442
|
+
${m.content}`).join("\n") : ""
|
|
443
|
+
].filter(Boolean).join("\n\n");
|
|
444
|
+
return {
|
|
445
|
+
errorCatalogueChunk: errorSection,
|
|
446
|
+
interactionPatternsChunk: interactionSection
|
|
447
|
+
};
|
|
422
448
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
lines.push(` - Fields: ${ws.fields.join(', ')}`);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
if ((result.focusTraps ?? []).length > 0) {
|
|
480
|
-
lines.push('\n### Focus-Trap Audit (observed)');
|
|
481
|
-
for (const ft of result.focusTraps) {
|
|
482
|
-
const verdict = ft.focusTrapped ? '✅ PASS' : '❌ FAIL (WCAG 2.1.2)';
|
|
483
|
-
lines.push(`- **${ft.modalTrigger}**: ${verdict} — focused: [${ft.sampledFocused.slice(0, 4).join(', ')}]`);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
if (result.conditionals.length > 0) {
|
|
487
|
-
lines.push('\n### Conditional UI (observed)');
|
|
488
|
-
for (const c of result.conditionals) {
|
|
489
|
-
lines.push(`- **${c.triggerElement}** = "${c.triggerValue}" reveals: ${c.revealedText}`);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
if (result.loadingPatterns.length > 0) {
|
|
493
|
-
lines.push('\n### Loading State Text (observed)');
|
|
494
|
-
for (const lp of result.loadingPatterns)
|
|
495
|
-
lines.push(`- "${lp}"`);
|
|
496
|
-
}
|
|
497
|
-
const full = lines.join('\n');
|
|
498
|
-
const errorSection = result.validationErrors.length > 0
|
|
499
|
-
? `## Real Validation Errors — ${result.url}\n` +
|
|
500
|
-
result.validationErrors.map((e) => `- **${e.field}**: "${e.errorText}"`).join('\n')
|
|
501
|
-
: '';
|
|
502
|
-
const interactionSection = [
|
|
503
|
-
result.successMessages.length > 0
|
|
504
|
-
? `## Success Messages — ${result.url}\n` + result.successMessages.map((s) => `- ${s.type}: "${s.content}"`).join('\n')
|
|
505
|
-
: '',
|
|
506
|
-
result.loadingPatterns.length > 0
|
|
507
|
-
? `## Loading Patterns — ${result.url}\n` + result.loadingPatterns.map((lp) => `- "${lp}"`).join('\n')
|
|
508
|
-
: '',
|
|
509
|
-
result.modals.length > 0
|
|
510
|
-
? `## Modal Contents — ${result.url}\n` + result.modals.map((m) => `### ${m.triggerElement}\n${m.content}`).join('\n')
|
|
511
|
-
: '',
|
|
512
|
-
].filter(Boolean).join('\n\n');
|
|
513
|
-
return {
|
|
514
|
-
errorCatalogueChunk: errorSection,
|
|
515
|
-
interactionPatternsChunk: interactionSection,
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
/**
|
|
519
|
-
* Run interactive probing for all screens discovered in a crawl session.
|
|
520
|
-
* Called from crawlProject() when opts.interactive = true.
|
|
521
|
-
*/
|
|
522
|
-
export async function probeAllScreens(page, tenantId, projectId, discovered, onProgress) {
|
|
523
|
-
const probeable = discovered.filter((d) => d.screenId && d.url && !d.url.includes('#'));
|
|
524
|
-
if (probeable.length === 0)
|
|
525
|
-
return;
|
|
526
|
-
const errorCatalogueChunks = [];
|
|
527
|
-
const interactionChunks = [];
|
|
528
|
-
for (let i = 0; i < probeable.length; i++) {
|
|
529
|
-
const screen = probeable[i];
|
|
530
|
-
const pct = 88 + Math.round((i / probeable.length) * 10);
|
|
531
|
-
await onProgress?.(pct, `Probing screen ${i + 1}/${probeable.length}: ${screen.url}`);
|
|
532
|
-
try {
|
|
533
|
-
// Navigate to screen
|
|
534
|
-
if (page.url() !== screen.url) {
|
|
535
|
-
await page.goto(screen.url, { waitUntil: 'domcontentloaded' });
|
|
536
|
-
await page.waitForTimeout(1500); // SPA hydration
|
|
537
|
-
}
|
|
538
|
-
const result = await probeScreen(page, screen.screenId, screen.url, screen.elements ?? []);
|
|
539
|
-
// Phase 5: save structured probe observations (replaces per-re-probe)
|
|
540
|
-
const observations = probeResultToObservations(result);
|
|
541
|
-
if (observations.length > 0) {
|
|
542
|
-
await AppBrain.saveProbeObservations(tenantId, projectId, screen.screenId, observations).catch(console.warn);
|
|
543
|
-
}
|
|
544
|
-
// Store raw probe result as KnowledgeChunk (kept for BM25 / oversized projects)
|
|
545
|
-
const md = probeResultToMarkdown(result);
|
|
546
|
-
if (md.errorCatalogueChunk)
|
|
547
|
-
errorCatalogueChunks.push(md.errorCatalogueChunk);
|
|
548
|
-
if (md.interactionPatternsChunk)
|
|
549
|
-
interactionChunks.push(md.interactionPatternsChunk);
|
|
550
|
-
const probeMarkdown = [md.errorCatalogueChunk, md.interactionPatternsChunk].filter(Boolean).join('\n\n');
|
|
551
|
-
if (probeMarkdown.length > 50) {
|
|
552
|
-
await AppBrain.saveKnowledgeChunk(tenantId, projectId, 'probe', screen.screenId, probeMarkdown, `Probe — ${screen.url}`).catch(console.warn);
|
|
553
|
-
}
|
|
554
|
-
if (result.validationErrors.length > 0) {
|
|
555
|
-
console.log(`[probe] ${screen.url}: ${result.validationErrors.length} validation errors, ${result.modals.length} modals`);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
catch (err) {
|
|
559
|
-
console.warn(`[probe] ✗ ${screen.url}:`, err instanceof Error ? err.message : err);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
// Update ERROR_CATALOGUE skill file with all real observed errors
|
|
563
|
-
if (errorCatalogueChunks.length > 0) {
|
|
564
|
-
const combined = `# Error Catalogue (Real Observed — Interactive Probe)\n\n${errorCatalogueChunks.join('\n\n')}`;
|
|
565
|
-
await AppBrain.upsertSkillFile(tenantId, projectId, 'ERROR_CATALOGUE', combined, {
|
|
566
|
-
summary: `Real validation error messages observed via interactive probe across ${errorCatalogueChunks.length} screens`,
|
|
567
|
-
scope: 'PROJECT',
|
|
568
|
-
}).catch(console.warn);
|
|
569
|
-
}
|
|
570
|
-
// Update INTERACTION_PATTERNS skill file with real observed patterns
|
|
571
|
-
if (interactionChunks.length > 0) {
|
|
572
|
-
const combined = `# Interaction Patterns (Real Observed — Interactive Probe)\n\n${interactionChunks.join('\n\n')}`;
|
|
573
|
-
await AppBrain.upsertSkillFile(tenantId, projectId, 'INTERACTION_PATTERNS', combined, {
|
|
574
|
-
summary: `Real loading states, success messages, and modal contents observed via interactive probe`,
|
|
575
|
-
scope: 'PROJECT',
|
|
576
|
-
}).catch(console.warn);
|
|
577
|
-
}
|
|
578
|
-
await onProgress?.(98, 'Interactive probe complete');
|
|
579
|
-
console.log(`[probe] Probed ${probeable.length} screens. Errors: ${errorCatalogueChunks.length}, Interactions: ${interactionChunks.length}`);
|
|
449
|
+
async function probeAllScreens(page, tenantId, projectId, discovered, onProgress) {
|
|
450
|
+
const probeable = discovered.filter((d) => d.screenId && d.url && !d.url.includes("#"));
|
|
451
|
+
if (probeable.length === 0) return;
|
|
452
|
+
const errorCatalogueChunks = [];
|
|
453
|
+
const interactionChunks = [];
|
|
454
|
+
for (let i = 0; i < probeable.length; i++) {
|
|
455
|
+
const screen = probeable[i];
|
|
456
|
+
const pct = 88 + Math.round(i / probeable.length * 10);
|
|
457
|
+
await onProgress?.(pct, `Probing screen ${i + 1}/${probeable.length}: ${screen.url}`);
|
|
458
|
+
try {
|
|
459
|
+
if (page.url() !== screen.url) {
|
|
460
|
+
await page.goto(screen.url, { waitUntil: "domcontentloaded" });
|
|
461
|
+
await page.waitForTimeout(1500);
|
|
462
|
+
}
|
|
463
|
+
const result = await probeScreen(page, screen.screenId, screen.url, screen.elements ?? []);
|
|
464
|
+
const observations = probeResultToObservations(result);
|
|
465
|
+
if (observations.length > 0) {
|
|
466
|
+
await AppBrain.saveProbeObservations(tenantId, projectId, screen.screenId, observations).catch(console.warn);
|
|
467
|
+
}
|
|
468
|
+
const md = probeResultToMarkdown(result);
|
|
469
|
+
if (md.errorCatalogueChunk) errorCatalogueChunks.push(md.errorCatalogueChunk);
|
|
470
|
+
if (md.interactionPatternsChunk) interactionChunks.push(md.interactionPatternsChunk);
|
|
471
|
+
const probeMarkdown = [md.errorCatalogueChunk, md.interactionPatternsChunk].filter(Boolean).join("\n\n");
|
|
472
|
+
if (probeMarkdown.length > 50) {
|
|
473
|
+
await AppBrain.saveKnowledgeChunk(tenantId, projectId, "probe", screen.screenId, probeMarkdown, `Probe \u2014 ${screen.url}`).catch(console.warn);
|
|
474
|
+
}
|
|
475
|
+
if (result.validationErrors.length > 0) {
|
|
476
|
+
console.log(`[probe] ${screen.url}: ${result.validationErrors.length} validation errors, ${result.modals.length} modals`);
|
|
477
|
+
}
|
|
478
|
+
} catch (err) {
|
|
479
|
+
console.warn(`[probe] \u2717 ${screen.url}:`, err instanceof Error ? err.message : err);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (errorCatalogueChunks.length > 0) {
|
|
483
|
+
const combined = `# Error Catalogue (Real Observed \u2014 Interactive Probe)
|
|
484
|
+
|
|
485
|
+
${errorCatalogueChunks.join("\n\n")}`;
|
|
486
|
+
await AppBrain.upsertSkillFile(tenantId, projectId, "ERROR_CATALOGUE", combined, {
|
|
487
|
+
summary: `Real validation error messages observed via interactive probe across ${errorCatalogueChunks.length} screens`,
|
|
488
|
+
scope: "PROJECT"
|
|
489
|
+
}).catch(console.warn);
|
|
490
|
+
}
|
|
491
|
+
if (interactionChunks.length > 0) {
|
|
492
|
+
const combined = `# Interaction Patterns (Real Observed \u2014 Interactive Probe)
|
|
493
|
+
|
|
494
|
+
${interactionChunks.join("\n\n")}`;
|
|
495
|
+
await AppBrain.upsertSkillFile(tenantId, projectId, "INTERACTION_PATTERNS", combined, {
|
|
496
|
+
summary: `Real loading states, success messages, and modal contents observed via interactive probe`,
|
|
497
|
+
scope: "PROJECT"
|
|
498
|
+
}).catch(console.warn);
|
|
499
|
+
}
|
|
500
|
+
await onProgress?.(98, "Interactive probe complete");
|
|
501
|
+
console.log(`[probe] Probed ${probeable.length} screens. Errors: ${errorCatalogueChunks.length}, Interactions: ${interactionChunks.length}`);
|
|
580
502
|
}
|
|
581
|
-
/** Verify skip-navigation links exist and their targets are present in the DOM. */
|
|
582
503
|
async function probeSkipNavigation(page) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
return issues;
|
|
504
|
+
const issues = [];
|
|
505
|
+
try {
|
|
506
|
+
const skipLinks = await page.evaluate(
|
|
507
|
+
() => Array.from(document.querySelectorAll('a[href^="#"]')).filter((el) => /skip|jump to|main content|content|nav/i.test(el.innerText?.toLowerCase().trim() || "")).map((el) => ({
|
|
508
|
+
text: el.innerText?.trim() || "",
|
|
509
|
+
href: el.getAttribute("href") || "",
|
|
510
|
+
hasTarget: !!document.querySelector(el.getAttribute("href") || "#__invalid__")
|
|
511
|
+
}))
|
|
512
|
+
);
|
|
513
|
+
if (skipLinks.length === 0) {
|
|
514
|
+
issues.push("No skip navigation links found (WCAG 2.4.1 \u2014 keyboard users cannot bypass nav blocks)");
|
|
515
|
+
}
|
|
516
|
+
for (const link of skipLinks) {
|
|
517
|
+
if (!link.hasTarget) issues.push(`Skip link "${link.text}" \u2192 "${link.href}" target not found in DOM`);
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
}
|
|
521
|
+
return issues;
|
|
602
522
|
}
|
|
603
|
-
/** Right-click on canvas, chart, and context-menu elements to capture context menu items. */
|
|
604
523
|
async function probeContextMenus(page) {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
await page.keyboard.press('Escape').catch(() => { });
|
|
620
|
-
}
|
|
621
|
-
catch { /* skip this target */ }
|
|
524
|
+
const results = [];
|
|
525
|
+
try {
|
|
526
|
+
const targets = await page.$$('canvas, [data-contextmenu], [oncontextmenu], [class*="chart" i]:not(script)');
|
|
527
|
+
for (const t of targets.slice(0, 4)) {
|
|
528
|
+
try {
|
|
529
|
+
await t.click({ button: "right", timeout: 1e3 });
|
|
530
|
+
await page.waitForTimeout(350);
|
|
531
|
+
const items = await page.evaluate(
|
|
532
|
+
() => Array.from(document.querySelectorAll('[role="menu"] [role="menuitem"], .context-menu-item, [class*="contextmenu" i] li')).map((el) => el.innerText?.trim()).filter(Boolean).slice(0, 12)
|
|
533
|
+
);
|
|
534
|
+
if (items.length > 0) {
|
|
535
|
+
const trigger = await t.evaluate((el) => el.getAttribute("aria-label") || el.className.slice(0, 40) || el.tagName).catch(() => "");
|
|
536
|
+
results.push({ trigger, items });
|
|
622
537
|
}
|
|
538
|
+
await page.keyboard.press("Escape").catch(() => {
|
|
539
|
+
});
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
623
542
|
}
|
|
624
|
-
|
|
625
|
-
|
|
543
|
+
} catch {
|
|
544
|
+
}
|
|
545
|
+
return results;
|
|
626
546
|
}
|
|
627
|
-
/** Click carousel next buttons up to 8 slides and record content of each revealed slide. */
|
|
628
547
|
async function probeCarousels(page) {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
548
|
+
const results = [];
|
|
549
|
+
try {
|
|
550
|
+
const CAROUSEL_SEL = '[class*="carousel" i], [class*="slider" i], [class*="swiper" i], [role="region"][aria-roledescription*="carousel" i]';
|
|
551
|
+
const hasCarousel = await page.evaluate((sel) => !!document.querySelector(sel), CAROUSEL_SEL);
|
|
552
|
+
if (!hasCarousel) return results;
|
|
553
|
+
const NEXT_SEL = '[aria-label*="next" i], [aria-label*="Next"], [class*="next" i]:not([class*="footnext"]):not(a), button[class*="arrow-right" i], button[class*="chevron-right" i]';
|
|
554
|
+
const slide0 = await page.evaluate((carSel) => {
|
|
555
|
+
const car = document.querySelector(carSel);
|
|
556
|
+
if (!car) return null;
|
|
557
|
+
const active = car.querySelector('[aria-hidden="false"], .active, [class*="active" i], [class*="current" i]');
|
|
558
|
+
const target = active ?? car;
|
|
559
|
+
return { heading: target.querySelector("h2,h3,h4")?.textContent?.trim() ?? "", content: target.innerText?.replace(/\s+/g, " ").trim().slice(0, 300) ?? "" };
|
|
560
|
+
}, CAROUSEL_SEL);
|
|
561
|
+
if (slide0?.content && slide0.content.length > 15) results.push({ slideIndex: 0, heading: slide0.heading || void 0, content: slide0.content });
|
|
562
|
+
const seen = new Set(slide0?.content ? [slide0.content] : []);
|
|
563
|
+
for (let i = 1; i < 8; i++) {
|
|
564
|
+
try {
|
|
565
|
+
const nextBtn = page.locator(NEXT_SEL).first();
|
|
566
|
+
const visible = await nextBtn.isVisible().catch(() => false);
|
|
567
|
+
if (!visible) break;
|
|
568
|
+
await nextBtn.click({ timeout: 1e3 });
|
|
569
|
+
await page.waitForTimeout(600);
|
|
570
|
+
const slide = await page.evaluate((carSel) => {
|
|
571
|
+
const car = document.querySelector(carSel);
|
|
572
|
+
if (!car) return null;
|
|
573
|
+
const active = car.querySelector('[aria-hidden="false"], .active, [class*="active" i], [class*="current" i]');
|
|
574
|
+
const target = active ?? car;
|
|
575
|
+
return { heading: target.querySelector("h2,h3,h4")?.textContent?.trim() ?? "", content: target.innerText?.replace(/\s+/g, " ").trim().slice(0, 300) ?? "" };
|
|
644
576
|
}, CAROUSEL_SEL);
|
|
645
|
-
if (
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
await page.waitForTimeout(600);
|
|
656
|
-
const slide = await page.evaluate((carSel) => {
|
|
657
|
-
const car = document.querySelector(carSel);
|
|
658
|
-
if (!car)
|
|
659
|
-
return null;
|
|
660
|
-
const active = car.querySelector('[aria-hidden="false"], .active, [class*="active" i], [class*="current" i]');
|
|
661
|
-
const target = active ?? car;
|
|
662
|
-
return { heading: target.querySelector('h2,h3,h4')?.textContent?.trim() ?? '', content: target.innerText?.replace(/\s+/g, ' ').trim().slice(0, 300) ?? '' };
|
|
663
|
-
}, CAROUSEL_SEL);
|
|
664
|
-
if (!slide?.content || slide.content.length < 15 || seen.has(slide.content))
|
|
665
|
-
break;
|
|
666
|
-
seen.add(slide.content);
|
|
667
|
-
results.push({ slideIndex: i, heading: slide.heading || undefined, content: slide.content });
|
|
668
|
-
}
|
|
669
|
-
catch {
|
|
670
|
-
break;
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
catch { /* non-fatal */ }
|
|
675
|
-
return results;
|
|
577
|
+
if (!slide?.content || slide.content.length < 15 || seen.has(slide.content)) break;
|
|
578
|
+
seen.add(slide.content);
|
|
579
|
+
results.push({ slideIndex: i, heading: slide.heading || void 0, content: slide.content });
|
|
580
|
+
} catch {
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
} catch {
|
|
585
|
+
}
|
|
586
|
+
return results;
|
|
676
587
|
}
|
|
677
|
-
/** Follow Next/Continue buttons to capture each wizard step's fields and content. */
|
|
678
588
|
async function probeWizardSteps(page) {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
catch { /* non-fatal */ }
|
|
719
|
-
return results;
|
|
589
|
+
const results = [];
|
|
590
|
+
try {
|
|
591
|
+
const NEXT_LABELS = /^(next|continue|proceed|step \d|›|→|forward)/i;
|
|
592
|
+
const BACK_LABELS = /^(back|previous|prev|‹|←)/i;
|
|
593
|
+
const WIZARD_INDICATORS = '[role="progressbar"], [class*="stepper" i], [class*="wizard" i], [class*="progress-step" i], [aria-label*="step" i]';
|
|
594
|
+
const isWizard = await page.evaluate((sel) => !!document.querySelector(sel), WIZARD_INDICATORS);
|
|
595
|
+
if (!isWizard) return results;
|
|
596
|
+
const captureStep = async (stepIndex) => {
|
|
597
|
+
const data = await page.evaluate(() => {
|
|
598
|
+
const content = document.querySelector('main, [role="main"], form')?.innerText?.replace(/\s+/g, " ").trim().slice(0, 400) ?? "";
|
|
599
|
+
const stepLabel = document.querySelector('[aria-current="step"], [class*="step-label" i], [class*="stepper-label" i]')?.textContent?.trim() ?? "";
|
|
600
|
+
const fields = Array.from(document.querySelectorAll('input:not([type="hidden"]), select, textarea')).map((el) => el.getAttribute("aria-label") || el.placeholder || el.name || "").filter(Boolean).slice(0, 10);
|
|
601
|
+
return { content, stepLabel, fields };
|
|
602
|
+
});
|
|
603
|
+
return { stepIndex, stepLabel: data.stepLabel || void 0, content: data.content, fields: data.fields };
|
|
604
|
+
};
|
|
605
|
+
results.push(await captureStep(0));
|
|
606
|
+
for (let i = 1; i < 6; i++) {
|
|
607
|
+
try {
|
|
608
|
+
const nextLabel = await page.evaluate((nextRe) => {
|
|
609
|
+
const btns = Array.from(document.querySelectorAll('button, [role="button"]'));
|
|
610
|
+
const btn2 = btns.find((b) => new RegExp(nextRe).test(b.innerText?.trim() || ""));
|
|
611
|
+
return btn2?.innerText?.trim() || null;
|
|
612
|
+
}, NEXT_LABELS.source);
|
|
613
|
+
if (!nextLabel) break;
|
|
614
|
+
const btn = page.locator(`button:has-text("${nextLabel}")`).first();
|
|
615
|
+
await btn.click({ timeout: 1500 });
|
|
616
|
+
await page.waitForTimeout(800);
|
|
617
|
+
results.push(await captureStep(i));
|
|
618
|
+
} catch {
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
} catch {
|
|
623
|
+
}
|
|
624
|
+
return results;
|
|
720
625
|
}
|
|
721
|
-
/** For each open modal, Tab 8× and verify focus stays inside the dialog (focus-trap check). */
|
|
722
626
|
async function detectFocusTraps(page) {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
627
|
+
const results = [];
|
|
628
|
+
try {
|
|
629
|
+
const openDialogs = await page.evaluate(() => {
|
|
630
|
+
return Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"]')).filter((el) => window.getComputedStyle(el).display !== "none").map((el) => el.getAttribute("aria-label") || el.querySelector('[role="heading"]')?.textContent?.trim() || "dialog");
|
|
631
|
+
});
|
|
632
|
+
for (const dialogLabel of openDialogs.slice(0, 3)) {
|
|
633
|
+
const sampled = [];
|
|
634
|
+
let allInside = true;
|
|
635
|
+
for (let i = 0; i < 8; i++) {
|
|
636
|
+
await page.keyboard.press("Tab");
|
|
637
|
+
await page.waitForTimeout(80);
|
|
638
|
+
const focused = await page.evaluate(() => {
|
|
639
|
+
const el = document.activeElement;
|
|
640
|
+
const inDialog = !!el?.closest('[role="dialog"], [role="alertdialog"]');
|
|
641
|
+
return { label: el?.getAttribute("aria-label") || el?.textContent?.trim().slice(0, 40) || el?.tagName || "", inDialog };
|
|
729
642
|
});
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
await page.keyboard.press('Tab');
|
|
735
|
-
await page.waitForTimeout(80);
|
|
736
|
-
const focused = await page.evaluate(() => {
|
|
737
|
-
const el = document.activeElement;
|
|
738
|
-
const inDialog = !!el?.closest('[role="dialog"], [role="alertdialog"]');
|
|
739
|
-
return { label: el?.getAttribute('aria-label') || el?.textContent?.trim().slice(0, 40) || el?.tagName || '', inDialog };
|
|
740
|
-
});
|
|
741
|
-
sampled.push(focused.label);
|
|
742
|
-
if (!focused.inDialog) {
|
|
743
|
-
allInside = false;
|
|
744
|
-
break;
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
results.push({ modalTrigger: dialogLabel, focusTrapped: allInside, sampledFocused: sampled });
|
|
643
|
+
sampled.push(focused.label);
|
|
644
|
+
if (!focused.inDialog) {
|
|
645
|
+
allInside = false;
|
|
646
|
+
break;
|
|
748
647
|
}
|
|
648
|
+
}
|
|
649
|
+
results.push({ modalTrigger: dialogLabel, focusTrapped: allInside, sampledFocused: sampled });
|
|
749
650
|
}
|
|
750
|
-
|
|
751
|
-
|
|
651
|
+
} catch {
|
|
652
|
+
}
|
|
653
|
+
return results;
|
|
752
654
|
}
|
|
753
|
-
/** Click each visible tab and record the panel content revealed. */
|
|
754
655
|
async function probeTabPanels(page) {
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
heading: panel.querySelector('h2,h3,h4')?.textContent?.trim() ?? '',
|
|
774
|
-
content: panel.innerText?.replace(/\s+/g, ' ').trim().slice(0, 400) ?? '',
|
|
775
|
-
};
|
|
776
|
-
});
|
|
777
|
-
if (panelContent?.content && panelContent.content.length > 15) {
|
|
778
|
-
results.push({ tabLabel: tabLabel.slice(0, 60), panelContent: panelContent.content, panelHeading: panelContent.heading || undefined });
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
catch { /* this tab failed, try next */ }
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
catch { /* non-fatal */ }
|
|
785
|
-
return results;
|
|
786
|
-
}
|
|
787
|
-
/** Hover elements with title/aria-describedby to capture tooltip text. */
|
|
788
|
-
async function probeTooltips(page) {
|
|
789
|
-
const results = [];
|
|
790
|
-
try {
|
|
791
|
-
const candidates = await page.evaluate(() => {
|
|
792
|
-
return Array.from(document.querySelectorAll('[title], [aria-describedby], [data-tooltip], [data-tip], [class*="tooltip" i]'))
|
|
793
|
-
.filter((el) => {
|
|
794
|
-
const style = window.getComputedStyle(el);
|
|
795
|
-
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
796
|
-
})
|
|
797
|
-
.slice(0, 8)
|
|
798
|
-
.map((el) => ({
|
|
799
|
-
label: el.innerText?.trim().slice(0, 60) || el.getAttribute('aria-label') || '',
|
|
800
|
-
title: el.getAttribute('title') || '',
|
|
801
|
-
describedBy: el.getAttribute('aria-describedby') || '',
|
|
802
|
-
selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
|
|
803
|
-
}));
|
|
656
|
+
const results = [];
|
|
657
|
+
try {
|
|
658
|
+
const tabs = await page.$$('[role="tab"]:not([aria-disabled="true"])');
|
|
659
|
+
for (const tab of tabs.slice(0, 10)) {
|
|
660
|
+
try {
|
|
661
|
+
const tabLabel = await tab.evaluate((el) => el.innerText?.trim() || el.getAttribute("aria-label") || "");
|
|
662
|
+
if (!tabLabel) continue;
|
|
663
|
+
await tab.click({ timeout: 1e3 });
|
|
664
|
+
await page.waitForTimeout(400);
|
|
665
|
+
const panelContent = await page.evaluate(() => {
|
|
666
|
+
const panel = document.querySelector('[role="tabpanel"]:not([hidden])');
|
|
667
|
+
if (!panel) return null;
|
|
668
|
+
const style = window.getComputedStyle(panel);
|
|
669
|
+
if (style.display === "none" || style.visibility === "hidden") return null;
|
|
670
|
+
return {
|
|
671
|
+
heading: panel.querySelector("h2,h3,h4")?.textContent?.trim() ?? "",
|
|
672
|
+
content: panel.innerText?.replace(/\s+/g, " ").trim().slice(0, 400) ?? ""
|
|
673
|
+
};
|
|
804
674
|
});
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
// Prefer title attribute directly — no need to hover for static tooltips
|
|
808
|
-
if (c.title && c.title.length > 5) {
|
|
809
|
-
results.push({ triggerLabel: c.label || c.selector, tooltipText: c.title.slice(0, 200) });
|
|
810
|
-
continue;
|
|
811
|
-
}
|
|
812
|
-
if (c.describedBy) {
|
|
813
|
-
const tooltipText = await page.evaluate((id) => document.getElementById(id)?.textContent?.trim() ?? '', c.describedBy);
|
|
814
|
-
if (tooltipText && tooltipText.length > 5) {
|
|
815
|
-
results.push({ triggerLabel: c.label || c.selector, tooltipText: tooltipText.slice(0, 200) });
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
catch { /* skip this element */ }
|
|
675
|
+
if (panelContent?.content && panelContent.content.length > 15) {
|
|
676
|
+
results.push({ tabLabel: tabLabel.slice(0, 60), panelContent: panelContent.content, panelHeading: panelContent.heading || void 0 });
|
|
820
677
|
}
|
|
678
|
+
} catch {
|
|
679
|
+
}
|
|
821
680
|
}
|
|
822
|
-
|
|
823
|
-
|
|
681
|
+
} catch {
|
|
682
|
+
}
|
|
683
|
+
return results;
|
|
824
684
|
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
role: active.getAttribute('role'),
|
|
845
|
-
ariaLabel: active.getAttribute('aria-label'),
|
|
846
|
-
text: (active.innerText ?? '').trim().slice(0, 60) || null,
|
|
847
|
-
outline,
|
|
848
|
-
tabIndex: active.tabIndex ?? null,
|
|
849
|
-
};
|
|
850
|
-
});
|
|
851
|
-
if (!el)
|
|
852
|
-
break;
|
|
853
|
-
const hasFocusIndicator = el.outline !== 'none' &&
|
|
854
|
-
el.outline !== '' &&
|
|
855
|
-
!el.outline.startsWith('0px') &&
|
|
856
|
-
!el.outline.startsWith('rgba(0, 0, 0, 0)') &&
|
|
857
|
-
!el.outline.startsWith('rgba(0,0,0,0)');
|
|
858
|
-
const focusable = {
|
|
859
|
-
tag: el.tag,
|
|
860
|
-
role: el.role,
|
|
861
|
-
ariaLabel: el.ariaLabel,
|
|
862
|
-
text: el.text,
|
|
863
|
-
hasFocusIndicator,
|
|
864
|
-
tabIndex: el.tabIndex,
|
|
865
|
-
};
|
|
866
|
-
const key = `${el.tag}|${el.text ?? ''}|${el.role ?? ''}`;
|
|
867
|
-
if (i === 0) {
|
|
868
|
-
firstKey = key;
|
|
869
|
-
}
|
|
870
|
-
else if (key === firstKey || seenKeys.has(key)) {
|
|
871
|
-
// Focus wrapped back to start or hit a duplicate — stop probing
|
|
872
|
-
break;
|
|
685
|
+
async function probeTooltips(page) {
|
|
686
|
+
const results = [];
|
|
687
|
+
try {
|
|
688
|
+
const candidates = await page.evaluate(() => {
|
|
689
|
+
return Array.from(document.querySelectorAll('[title], [aria-describedby], [data-tooltip], [data-tip], [class*="tooltip" i]')).filter((el) => {
|
|
690
|
+
const style = window.getComputedStyle(el);
|
|
691
|
+
return style.display !== "none" && style.visibility !== "hidden";
|
|
692
|
+
}).slice(0, 8).map((el) => ({
|
|
693
|
+
label: el.innerText?.trim().slice(0, 60) || el.getAttribute("aria-label") || "",
|
|
694
|
+
title: el.getAttribute("title") || "",
|
|
695
|
+
describedBy: el.getAttribute("aria-describedby") || "",
|
|
696
|
+
selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : "")
|
|
697
|
+
}));
|
|
698
|
+
});
|
|
699
|
+
for (const c of candidates) {
|
|
700
|
+
try {
|
|
701
|
+
if (c.title && c.title.length > 5) {
|
|
702
|
+
results.push({ triggerLabel: c.label || c.selector, tooltipText: c.title.slice(0, 200) });
|
|
703
|
+
continue;
|
|
873
704
|
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
705
|
+
if (c.describedBy) {
|
|
706
|
+
const tooltipText = await page.evaluate((id) => document.getElementById(id)?.textContent?.trim() ?? "", c.describedBy);
|
|
707
|
+
if (tooltipText && tooltipText.length > 5) {
|
|
708
|
+
results.push({ triggerLabel: c.label || c.selector, tooltipText: tooltipText.slice(0, 200) });
|
|
709
|
+
}
|
|
878
710
|
}
|
|
711
|
+
} catch {
|
|
712
|
+
}
|
|
879
713
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
714
|
+
} catch {
|
|
715
|
+
}
|
|
716
|
+
return results;
|
|
717
|
+
}
|
|
718
|
+
async function probeKeyboardNav(page) {
|
|
719
|
+
const sequence = [];
|
|
720
|
+
const issues = [];
|
|
721
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
722
|
+
let firstKey = null;
|
|
723
|
+
for (let i = 0; i < 50; i++) {
|
|
724
|
+
await page.keyboard.press("Tab");
|
|
725
|
+
const el = await page.evaluate(() => {
|
|
726
|
+
const active = document.activeElement;
|
|
727
|
+
if (!active || active === document.body) return null;
|
|
728
|
+
const outline = window.getComputedStyle(active).outline;
|
|
729
|
+
return {
|
|
730
|
+
tag: active.tagName.toLowerCase(),
|
|
731
|
+
role: active.getAttribute("role"),
|
|
732
|
+
ariaLabel: active.getAttribute("aria-label"),
|
|
733
|
+
text: (active.innerText ?? "").trim().slice(0, 60) || null,
|
|
734
|
+
outline,
|
|
735
|
+
tabIndex: active.tabIndex ?? null
|
|
736
|
+
};
|
|
737
|
+
});
|
|
738
|
+
if (!el) break;
|
|
739
|
+
const hasFocusIndicator = el.outline !== "none" && el.outline !== "" && !el.outline.startsWith("0px") && !el.outline.startsWith("rgba(0, 0, 0, 0)") && !el.outline.startsWith("rgba(0,0,0,0)");
|
|
740
|
+
const focusable = {
|
|
741
|
+
tag: el.tag,
|
|
742
|
+
role: el.role,
|
|
743
|
+
ariaLabel: el.ariaLabel,
|
|
744
|
+
text: el.text,
|
|
745
|
+
hasFocusIndicator,
|
|
746
|
+
tabIndex: el.tabIndex
|
|
885
747
|
};
|
|
748
|
+
const key = `${el.tag}|${el.text ?? ""}|${el.role ?? ""}`;
|
|
749
|
+
if (i === 0) {
|
|
750
|
+
firstKey = key;
|
|
751
|
+
} else if (key === firstKey || seenKeys.has(key)) {
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
seenKeys.add(key);
|
|
755
|
+
sequence.push(focusable);
|
|
756
|
+
if (!hasFocusIndicator) {
|
|
757
|
+
issues.push(`Element #${i + 1} (${el.tag}) has no visible focus indicator`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
focusableCount: sequence.length,
|
|
762
|
+
elementsWithNoFocusIndicator: issues.length,
|
|
763
|
+
sequence,
|
|
764
|
+
issues
|
|
765
|
+
};
|
|
886
766
|
}
|
|
767
|
+
export {
|
|
768
|
+
probeAllScreens,
|
|
769
|
+
probeKeyboardNav,
|
|
770
|
+
probeResultToMarkdown,
|
|
771
|
+
probeResultToObservations,
|
|
772
|
+
probeScreen
|
|
773
|
+
};
|
|
774
|
+
//# sourceMappingURL=interactive-probe.js.map
|