btcp-browser-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +230 -0
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/SKILL.md +143 -0
- package/SNAPSHOT_IMPROVEMENTS.md +302 -0
- package/USAGE.md +146 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/docs/browser-cli-design.md +500 -0
- package/examples/chrome-extension/CHANGELOG.md +210 -0
- package/examples/chrome-extension/DEBUG.md +231 -0
- package/examples/chrome-extension/ERROR_FIXED.md +147 -0
- package/examples/chrome-extension/QUICK_TEST.md +189 -0
- package/examples/chrome-extension/README.md +149 -0
- package/examples/chrome-extension/SESSION_ONLY_MODE.md +305 -0
- package/examples/chrome-extension/TEST_WITH_YOUR_TABS.md +97 -0
- package/examples/chrome-extension/build.js +43 -0
- package/examples/chrome-extension/manifest.json +37 -0
- package/examples/chrome-extension/package-lock.json +1063 -0
- package/examples/chrome-extension/package.json +21 -0
- package/examples/chrome-extension/popup.html +195 -0
- package/examples/chrome-extension/src/background.ts +12 -0
- package/examples/chrome-extension/src/content.ts +7 -0
- package/examples/chrome-extension/src/popup.ts +303 -0
- package/examples/chrome-extension/src/scenario-google-github.ts +389 -0
- package/examples/chrome-extension/test-page.html +127 -0
- package/examples/chrome-extension/tests/README.md +206 -0
- package/examples/chrome-extension/tests/scenario-google-to-github-star.ts +380 -0
- package/examples/chrome-extension/tsconfig.json +14 -0
- package/examples/snapshots/README.md +207 -0
- package/examples/snapshots/amazon-com-detail.html +9528 -0
- package/examples/snapshots/amazon-com-detail.snapshot.txt +997 -0
- package/examples/snapshots/convert-snapshots.ts +97 -0
- package/examples/snapshots/edition-cnn-com.html +13292 -0
- package/examples/snapshots/edition-cnn-com.snapshot.txt +562 -0
- package/examples/snapshots/github-com-microsoft-vscode.html +2916 -0
- package/examples/snapshots/github-com-microsoft-vscode.snapshot.txt +455 -0
- package/examples/snapshots/google-search.html +20012 -0
- package/examples/snapshots/google-search.snapshot.txt +195 -0
- package/examples/snapshots/metadata.json +86 -0
- package/examples/snapshots/npr-org-templates.html +2031 -0
- package/examples/snapshots/npr-org-templates.snapshot.txt +224 -0
- package/examples/snapshots/stackoverflow-com.html +5216 -0
- package/examples/snapshots/stackoverflow-com.snapshot.txt +2404 -0
- package/examples/snapshots/test-all-mode.html +46 -0
- package/examples/snapshots/test-all-mode.snapshot.txt +5 -0
- package/examples/snapshots/validate.test.ts +296 -0
- package/package.json +65 -0
- package/packages/cli/package.json +42 -0
- package/packages/cli/src/__tests__/cli.test.ts +434 -0
- package/packages/cli/src/__tests__/errors.test.ts +226 -0
- package/packages/cli/src/__tests__/executor.test.ts +275 -0
- package/packages/cli/src/__tests__/formatter.test.ts +260 -0
- package/packages/cli/src/__tests__/parser.test.ts +288 -0
- package/packages/cli/src/__tests__/suggestions.test.ts +255 -0
- package/packages/cli/src/commands/back.ts +22 -0
- package/packages/cli/src/commands/check.ts +33 -0
- package/packages/cli/src/commands/clear.ts +33 -0
- package/packages/cli/src/commands/click.ts +32 -0
- package/packages/cli/src/commands/closetab.ts +31 -0
- package/packages/cli/src/commands/eval.ts +41 -0
- package/packages/cli/src/commands/fill.ts +30 -0
- package/packages/cli/src/commands/focus.ts +33 -0
- package/packages/cli/src/commands/forward.ts +22 -0
- package/packages/cli/src/commands/goto.ts +34 -0
- package/packages/cli/src/commands/help.ts +162 -0
- package/packages/cli/src/commands/hover.ts +34 -0
- package/packages/cli/src/commands/index.ts +129 -0
- package/packages/cli/src/commands/newtab.ts +35 -0
- package/packages/cli/src/commands/press.ts +40 -0
- package/packages/cli/src/commands/reload.ts +25 -0
- package/packages/cli/src/commands/screenshot.ts +27 -0
- package/packages/cli/src/commands/scroll.ts +64 -0
- package/packages/cli/src/commands/select.ts +35 -0
- package/packages/cli/src/commands/snapshot.ts +21 -0
- package/packages/cli/src/commands/tab.ts +32 -0
- package/packages/cli/src/commands/tabs.ts +26 -0
- package/packages/cli/src/commands/text.ts +27 -0
- package/packages/cli/src/commands/title.ts +17 -0
- package/packages/cli/src/commands/type.ts +38 -0
- package/packages/cli/src/commands/uncheck.ts +33 -0
- package/packages/cli/src/commands/url.ts +17 -0
- package/packages/cli/src/commands/wait.ts +54 -0
- package/packages/cli/src/errors.ts +164 -0
- package/packages/cli/src/executor.ts +68 -0
- package/packages/cli/src/formatter.ts +215 -0
- package/packages/cli/src/index.ts +257 -0
- package/packages/cli/src/parser.ts +195 -0
- package/packages/cli/src/suggestions.ts +207 -0
- package/packages/cli/src/terminal/Terminal.ts +365 -0
- package/packages/cli/src/terminal/index.ts +5 -0
- package/packages/cli/src/types.ts +155 -0
- package/packages/cli/tsconfig.json +20 -0
- package/packages/core/package.json +35 -0
- package/packages/core/src/actions.ts +1210 -0
- package/packages/core/src/errors.ts +296 -0
- package/packages/core/src/index.test.ts +638 -0
- package/packages/core/src/index.ts +220 -0
- package/packages/core/src/ref-map.ts +107 -0
- package/packages/core/src/snapshot.ts +873 -0
- package/packages/core/src/types.ts +536 -0
- package/packages/core/tsconfig.json +23 -0
- package/packages/extension/README.md +129 -0
- package/packages/extension/package.json +43 -0
- package/packages/extension/src/background.ts +888 -0
- package/packages/extension/src/content.ts +172 -0
- package/packages/extension/src/index.ts +579 -0
- package/packages/extension/src/session-manager.ts +385 -0
- package/packages/extension/src/session-types.ts +144 -0
- package/packages/extension/src/types.ts +162 -0
- package/packages/extension/tsconfig.json +28 -0
- package/src/index.ts +64 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @btcp/core - DOM Snapshot
|
|
3
|
+
*
|
|
4
|
+
* Generates a flat accessibility snapshot of the DOM.
|
|
5
|
+
* Produces a compact, AI-friendly list of interactive elements.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SnapshotData, RefMap } from './types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get HTML element constructors from window (works in both browser and jsdom)
|
|
12
|
+
*/
|
|
13
|
+
function getHTMLConstructors(element: Element) {
|
|
14
|
+
const win = element.ownerDocument.defaultView;
|
|
15
|
+
if (!win) {
|
|
16
|
+
return {
|
|
17
|
+
HTMLElement: null,
|
|
18
|
+
HTMLInputElement: null,
|
|
19
|
+
HTMLTextAreaElement: null,
|
|
20
|
+
HTMLSelectElement: null,
|
|
21
|
+
HTMLAnchorElement: null,
|
|
22
|
+
HTMLButtonElement: null,
|
|
23
|
+
HTMLImageElement: null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
HTMLElement: win.HTMLElement,
|
|
28
|
+
HTMLInputElement: win.HTMLInputElement,
|
|
29
|
+
HTMLTextAreaElement: win.HTMLTextAreaElement,
|
|
30
|
+
HTMLSelectElement: win.HTMLSelectElement,
|
|
31
|
+
HTMLAnchorElement: win.HTMLAnchorElement,
|
|
32
|
+
HTMLButtonElement: win.HTMLButtonElement,
|
|
33
|
+
HTMLImageElement: win.HTMLImageElement,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Grep options (mirrors Unix grep flags)
|
|
39
|
+
*/
|
|
40
|
+
interface GrepOptions {
|
|
41
|
+
/** Pattern to search for */
|
|
42
|
+
pattern: string;
|
|
43
|
+
/** Case-insensitive matching (grep -i) */
|
|
44
|
+
ignoreCase?: boolean;
|
|
45
|
+
/** Invert match - return non-matching lines (grep -v) */
|
|
46
|
+
invert?: boolean;
|
|
47
|
+
/** Treat pattern as fixed string, not regex (grep -F) */
|
|
48
|
+
fixedStrings?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface SnapshotOptions {
|
|
52
|
+
root?: Element;
|
|
53
|
+
maxDepth?: number;
|
|
54
|
+
includeHidden?: boolean;
|
|
55
|
+
interactive?: boolean;
|
|
56
|
+
compact?: boolean;
|
|
57
|
+
all?: boolean;
|
|
58
|
+
format?: 'tree' | 'html';
|
|
59
|
+
/** Grep filter - string pattern or options object */
|
|
60
|
+
grep?: string | GrepOptions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const TRUNCATE_LIMITS = {
|
|
64
|
+
ELEMENT_NAME: 50,
|
|
65
|
+
TEXT_SHORT: 80,
|
|
66
|
+
TEXT_LONG: 120,
|
|
67
|
+
ERROR_MESSAGE: 100,
|
|
68
|
+
URL: 150,
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
// Role mappings for implicit ARIA roles
|
|
72
|
+
const IMPLICIT_ROLES: Record<string, string> = {
|
|
73
|
+
A: 'link',
|
|
74
|
+
ARTICLE: 'article',
|
|
75
|
+
ASIDE: 'complementary',
|
|
76
|
+
BUTTON: 'button',
|
|
77
|
+
DIALOG: 'dialog',
|
|
78
|
+
FOOTER: 'contentinfo',
|
|
79
|
+
FORM: 'form',
|
|
80
|
+
H1: 'heading',
|
|
81
|
+
H2: 'heading',
|
|
82
|
+
H3: 'heading',
|
|
83
|
+
H4: 'heading',
|
|
84
|
+
H5: 'heading',
|
|
85
|
+
H6: 'heading',
|
|
86
|
+
HEADER: 'banner',
|
|
87
|
+
IMG: 'img',
|
|
88
|
+
INPUT: 'textbox',
|
|
89
|
+
LI: 'listitem',
|
|
90
|
+
MAIN: 'main',
|
|
91
|
+
NAV: 'navigation',
|
|
92
|
+
OL: 'list',
|
|
93
|
+
OPTION: 'option',
|
|
94
|
+
PROGRESS: 'progressbar',
|
|
95
|
+
SECTION: 'region',
|
|
96
|
+
SELECT: 'combobox',
|
|
97
|
+
TABLE: 'table',
|
|
98
|
+
TBODY: 'rowgroup',
|
|
99
|
+
TD: 'cell',
|
|
100
|
+
TEXTAREA: 'textbox',
|
|
101
|
+
TH: 'columnheader',
|
|
102
|
+
THEAD: 'rowgroup',
|
|
103
|
+
TR: 'row',
|
|
104
|
+
UL: 'list',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Input type to role mapping
|
|
108
|
+
const INPUT_ROLES: Record<string, string> = {
|
|
109
|
+
button: 'button',
|
|
110
|
+
checkbox: 'checkbox',
|
|
111
|
+
email: 'textbox',
|
|
112
|
+
number: 'spinbutton',
|
|
113
|
+
password: 'textbox',
|
|
114
|
+
radio: 'radio',
|
|
115
|
+
range: 'slider',
|
|
116
|
+
search: 'searchbox',
|
|
117
|
+
submit: 'button',
|
|
118
|
+
tel: 'textbox',
|
|
119
|
+
text: 'textbox',
|
|
120
|
+
url: 'textbox',
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get the ARIA role for an element
|
|
125
|
+
*/
|
|
126
|
+
function getRole(element: Element): string | null {
|
|
127
|
+
const explicitRole = element.getAttribute('role');
|
|
128
|
+
if (explicitRole) return explicitRole;
|
|
129
|
+
|
|
130
|
+
const tagName = element.tagName;
|
|
131
|
+
|
|
132
|
+
// Special handling for headings - include level
|
|
133
|
+
if (tagName.match(/^H[1-6]$/)) {
|
|
134
|
+
const level = tagName[1];
|
|
135
|
+
return `heading level=${level}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Special handling for inputs
|
|
139
|
+
if (tagName === 'INPUT') {
|
|
140
|
+
const type = (element as HTMLInputElement).type || 'text';
|
|
141
|
+
return INPUT_ROLES[type] || 'textbox';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return IMPLICIT_ROLES[tagName] || null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get input type and validation attributes
|
|
149
|
+
*/
|
|
150
|
+
function getInputAttributes(element: Element): string {
|
|
151
|
+
const constructors = getHTMLConstructors(element);
|
|
152
|
+
|
|
153
|
+
const isInput = constructors.HTMLInputElement && element instanceof constructors.HTMLInputElement;
|
|
154
|
+
const isTextArea = constructors.HTMLTextAreaElement && element instanceof constructors.HTMLTextAreaElement;
|
|
155
|
+
|
|
156
|
+
if (!(isInput || isTextArea)) {
|
|
157
|
+
return '';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const attrs: string[] = [];
|
|
161
|
+
|
|
162
|
+
if (isInput && (element as HTMLInputElement).type && (element as HTMLInputElement).type !== 'text') {
|
|
163
|
+
attrs.push(`type=${(element as HTMLInputElement).type}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if ((element as HTMLInputElement | HTMLTextAreaElement).required) attrs.push('required');
|
|
167
|
+
if (element.getAttribute('aria-invalid') === 'true') attrs.push('invalid');
|
|
168
|
+
|
|
169
|
+
if (isInput) {
|
|
170
|
+
const input = element as HTMLInputElement;
|
|
171
|
+
if (input.minLength > 0) attrs.push(`minlength=${input.minLength}`);
|
|
172
|
+
if (input.maxLength >= 0 && input.maxLength < 524288) attrs.push(`maxlength=${input.maxLength}`);
|
|
173
|
+
if (input.pattern) attrs.push(`pattern=${input.pattern}`);
|
|
174
|
+
if (input.min) attrs.push(`min=${input.min}`);
|
|
175
|
+
if (input.max) attrs.push(`max=${input.max}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return attrs.length > 0 ? ` [${attrs.join(' ')}]` : '';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check if element is in viewport
|
|
183
|
+
*/
|
|
184
|
+
function isInViewport(element: Element, window: Window): boolean {
|
|
185
|
+
const rect = element.getBoundingClientRect();
|
|
186
|
+
return (
|
|
187
|
+
rect.top >= 0 &&
|
|
188
|
+
rect.left >= 0 &&
|
|
189
|
+
rect.bottom <= (window.innerHeight || window.document.documentElement.clientHeight) &&
|
|
190
|
+
rect.right <= (window.innerWidth || window.document.documentElement.clientWidth)
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get label from enclosing <label> element
|
|
196
|
+
*/
|
|
197
|
+
function getEnclosingLabel(element: Element): string {
|
|
198
|
+
const label = element.closest('label');
|
|
199
|
+
if (label) {
|
|
200
|
+
const clone = label.cloneNode(true) as HTMLElement;
|
|
201
|
+
const inputs = clone.querySelectorAll('input, textarea, select');
|
|
202
|
+
inputs.forEach(input => input.remove());
|
|
203
|
+
return clone.textContent?.trim() || '';
|
|
204
|
+
}
|
|
205
|
+
return '';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get label for button elements
|
|
210
|
+
*/
|
|
211
|
+
function getButtonLabel(element: HTMLButtonElement | HTMLInputElement): string {
|
|
212
|
+
const constructors = getHTMLConstructors(element);
|
|
213
|
+
const isInputElement = constructors.HTMLInputElement && element instanceof constructors.HTMLInputElement;
|
|
214
|
+
|
|
215
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
216
|
+
if (ariaLabel) return ariaLabel.trim();
|
|
217
|
+
|
|
218
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
219
|
+
if (labelledBy) {
|
|
220
|
+
const labels = labelledBy
|
|
221
|
+
.split(/\s+/)
|
|
222
|
+
.map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim())
|
|
223
|
+
.filter(Boolean);
|
|
224
|
+
if (labels.length) return labels.join(' ');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const textContent = element.textContent?.trim();
|
|
228
|
+
if (textContent) return textContent;
|
|
229
|
+
|
|
230
|
+
if (isInputElement && ['submit', 'button', 'reset'].includes((element as HTMLInputElement).type)) {
|
|
231
|
+
if (element.value) return element.value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const title = element.getAttribute('title');
|
|
235
|
+
if (title) return title.trim();
|
|
236
|
+
|
|
237
|
+
return '';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get label for link elements
|
|
242
|
+
*/
|
|
243
|
+
function getLinkLabel(element: HTMLAnchorElement): string {
|
|
244
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
245
|
+
if (ariaLabel) return ariaLabel.trim();
|
|
246
|
+
|
|
247
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
248
|
+
if (labelledBy) {
|
|
249
|
+
const labels = labelledBy
|
|
250
|
+
.split(/\s+/)
|
|
251
|
+
.map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim())
|
|
252
|
+
.filter(Boolean);
|
|
253
|
+
if (labels.length) return labels.join(' ');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const textContent = element.textContent?.trim();
|
|
257
|
+
if (textContent) return textContent;
|
|
258
|
+
|
|
259
|
+
const title = element.getAttribute('title');
|
|
260
|
+
if (title) return title.trim();
|
|
261
|
+
|
|
262
|
+
const href = element.getAttribute('href');
|
|
263
|
+
if (href) {
|
|
264
|
+
const path = href.split('?')[0].split('#')[0];
|
|
265
|
+
const segments = path.split('/').filter(Boolean);
|
|
266
|
+
const lastSegment = segments[segments.length - 1] || segments[segments.length - 2];
|
|
267
|
+
if (lastSegment) {
|
|
268
|
+
return lastSegment.replace(/[-_]/g, ' ').replace(/\.\w+$/, '');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return '';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get label for input/textarea/select elements
|
|
277
|
+
*/
|
|
278
|
+
function getFormControlLabel(element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement): string {
|
|
279
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
280
|
+
if (ariaLabel) return ariaLabel.trim();
|
|
281
|
+
|
|
282
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
283
|
+
if (labelledBy) {
|
|
284
|
+
const labels = labelledBy
|
|
285
|
+
.split(/\s+/)
|
|
286
|
+
.map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim())
|
|
287
|
+
.filter(Boolean);
|
|
288
|
+
if (labels.length) return labels.join(' ');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const id = element.getAttribute('id');
|
|
292
|
+
if (id) {
|
|
293
|
+
const label = element.ownerDocument.querySelector(`label[for="${id}"]`);
|
|
294
|
+
if (label) {
|
|
295
|
+
const labelText = label.textContent?.trim();
|
|
296
|
+
if (labelText) return labelText;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const enclosingLabel = getEnclosingLabel(element);
|
|
301
|
+
if (enclosingLabel) return enclosingLabel;
|
|
302
|
+
|
|
303
|
+
const title = element.getAttribute('title');
|
|
304
|
+
if (title) return title.trim();
|
|
305
|
+
|
|
306
|
+
return '';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Get label for image elements
|
|
311
|
+
*/
|
|
312
|
+
function getImageLabel(element: HTMLImageElement): string {
|
|
313
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
314
|
+
if (ariaLabel) return ariaLabel.trim();
|
|
315
|
+
|
|
316
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
317
|
+
if (labelledBy) {
|
|
318
|
+
const labels = labelledBy
|
|
319
|
+
.split(/\s+/)
|
|
320
|
+
.map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim())
|
|
321
|
+
.filter(Boolean);
|
|
322
|
+
if (labels.length) return labels.join(' ');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const alt = element.getAttribute('alt');
|
|
326
|
+
if (alt) return alt.trim();
|
|
327
|
+
|
|
328
|
+
const title = element.getAttribute('title');
|
|
329
|
+
if (title) return title.trim();
|
|
330
|
+
|
|
331
|
+
const src = element.getAttribute('src');
|
|
332
|
+
if (src) {
|
|
333
|
+
const filename = src.split('/').pop()?.split('?')[0].replace(/\.\w+$/, '');
|
|
334
|
+
if (filename) return filename.replace(/[-_]/g, ' ');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return '';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Get accessible name for an element
|
|
342
|
+
*/
|
|
343
|
+
function getAccessibleName(element: Element): string {
|
|
344
|
+
const constructors = getHTMLConstructors(element);
|
|
345
|
+
|
|
346
|
+
const isButton = constructors.HTMLButtonElement && element instanceof constructors.HTMLButtonElement;
|
|
347
|
+
const isInputButton = constructors.HTMLInputElement &&
|
|
348
|
+
element instanceof constructors.HTMLInputElement &&
|
|
349
|
+
['button', 'submit', 'reset'].includes((element as HTMLInputElement).type);
|
|
350
|
+
|
|
351
|
+
if (isButton || isInputButton) {
|
|
352
|
+
return getButtonLabel(element as HTMLButtonElement | HTMLInputElement);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const isAnchor = constructors.HTMLAnchorElement && element instanceof constructors.HTMLAnchorElement;
|
|
356
|
+
if (isAnchor) {
|
|
357
|
+
return getLinkLabel(element as HTMLAnchorElement);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const isInput = constructors.HTMLInputElement && element instanceof constructors.HTMLInputElement;
|
|
361
|
+
const isTextArea = constructors.HTMLTextAreaElement && element instanceof constructors.HTMLTextAreaElement;
|
|
362
|
+
const isSelect = constructors.HTMLSelectElement && element instanceof constructors.HTMLSelectElement;
|
|
363
|
+
|
|
364
|
+
if (isInput || isTextArea || isSelect) {
|
|
365
|
+
return getFormControlLabel(element as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const isImage = constructors.HTMLImageElement && element instanceof constructors.HTMLImageElement;
|
|
369
|
+
if (isImage) {
|
|
370
|
+
return getImageLabel(element as HTMLImageElement);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
374
|
+
if (ariaLabel) return ariaLabel.trim();
|
|
375
|
+
|
|
376
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
377
|
+
if (labelledBy) {
|
|
378
|
+
const labels = labelledBy
|
|
379
|
+
.split(/\s+/)
|
|
380
|
+
.map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim())
|
|
381
|
+
.filter(Boolean);
|
|
382
|
+
if (labels.length) return labels.join(' ');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const textContent = element.textContent?.trim();
|
|
386
|
+
if (textContent) return textContent;
|
|
387
|
+
|
|
388
|
+
return '';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Check if element or any of its ancestors are hidden
|
|
393
|
+
* This checks the full ancestor chain for proper visibility detection
|
|
394
|
+
*/
|
|
395
|
+
function isVisible(element: Element, checkAncestors: boolean = true): boolean {
|
|
396
|
+
const win = element.ownerDocument.defaultView;
|
|
397
|
+
if (!win) return true;
|
|
398
|
+
|
|
399
|
+
const HTMLElementConstructor = win.HTMLElement;
|
|
400
|
+
if (!(element instanceof HTMLElementConstructor)) return true;
|
|
401
|
+
|
|
402
|
+
// Check inline styles first for performance
|
|
403
|
+
const inlineDisplay = element.style.display;
|
|
404
|
+
const inlineVisibility = element.style.visibility;
|
|
405
|
+
if (inlineDisplay === 'none') return false;
|
|
406
|
+
if (inlineVisibility === 'hidden') return false;
|
|
407
|
+
|
|
408
|
+
// Check computed styles (but be defensive about failures)
|
|
409
|
+
try {
|
|
410
|
+
const style = win.getComputedStyle(element);
|
|
411
|
+
if (style) {
|
|
412
|
+
if (style.display === 'none') return false;
|
|
413
|
+
if (style.visibility === 'hidden') return false;
|
|
414
|
+
if (style.opacity === '0') return false;
|
|
415
|
+
}
|
|
416
|
+
} catch (e) {
|
|
417
|
+
// If getComputedStyle fails (e.g., on intermediate pages), assume visible
|
|
418
|
+
// This is safer than assuming hidden
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (element.hidden) return false;
|
|
422
|
+
|
|
423
|
+
// Check ancestors if requested (for proper visibility detection)
|
|
424
|
+
if (checkAncestors && element.parentElement) {
|
|
425
|
+
return isVisible(element.parentElement, true);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Check if element is interactive
|
|
433
|
+
*/
|
|
434
|
+
function isInteractive(element: Element): boolean {
|
|
435
|
+
if (element.tagName === 'A' && !element.hasAttribute('href')) {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const role = getRole(element);
|
|
440
|
+
if (!role) return false;
|
|
441
|
+
|
|
442
|
+
const interactiveRoles = [
|
|
443
|
+
'button',
|
|
444
|
+
'link',
|
|
445
|
+
'textbox',
|
|
446
|
+
'checkbox',
|
|
447
|
+
'radio',
|
|
448
|
+
'combobox',
|
|
449
|
+
'listbox',
|
|
450
|
+
'menuitem',
|
|
451
|
+
'option',
|
|
452
|
+
'slider',
|
|
453
|
+
'spinbutton',
|
|
454
|
+
'switch',
|
|
455
|
+
'tab',
|
|
456
|
+
'searchbox',
|
|
457
|
+
];
|
|
458
|
+
|
|
459
|
+
return interactiveRoles.includes(role);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Truncate string with context-aware limits
|
|
464
|
+
*/
|
|
465
|
+
function truncateByType(str: string, type: keyof typeof TRUNCATE_LIMITS): string {
|
|
466
|
+
const maxLength = TRUNCATE_LIMITS[type];
|
|
467
|
+
const cleaned = str.replace(/\s+/g, ' ').trim();
|
|
468
|
+
if (cleaned.length <= maxLength) return cleaned;
|
|
469
|
+
return cleaned.slice(0, maxLength - 3) + '...';
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Escape CSS identifiers
|
|
474
|
+
*/
|
|
475
|
+
function cssEscape(value: string): string {
|
|
476
|
+
return value.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Generate a CSS selector for an element
|
|
481
|
+
*/
|
|
482
|
+
function generateSelector(element: Element): string {
|
|
483
|
+
try {
|
|
484
|
+
const win = element.ownerDocument.defaultView;
|
|
485
|
+
const escape = (win && 'CSS' in win && win.CSS && 'escape' in win.CSS)
|
|
486
|
+
? (s: string) => win.CSS.escape(s)
|
|
487
|
+
: cssEscape;
|
|
488
|
+
|
|
489
|
+
if (element.id) {
|
|
490
|
+
try {
|
|
491
|
+
return `#${escape(element.id)}`;
|
|
492
|
+
} catch {
|
|
493
|
+
// ID escaping failed, fall through
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const testId = element.getAttribute('data-testid');
|
|
498
|
+
if (testId) {
|
|
499
|
+
return `[data-testid="${testId}"]`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const parts: string[] = [];
|
|
503
|
+
let current: Element | null = element;
|
|
504
|
+
|
|
505
|
+
while (current && current !== element.ownerDocument.body) {
|
|
506
|
+
let selector = current.tagName.toLowerCase();
|
|
507
|
+
|
|
508
|
+
if (current.className && typeof current.className === 'string') {
|
|
509
|
+
try {
|
|
510
|
+
const classes = current.className.trim().split(/\s+/).filter(c => c.length < 30 && c.length > 0);
|
|
511
|
+
if (classes.length) {
|
|
512
|
+
selector += `.${classes.slice(0, 2).map(c => escape(c)).join('.')}`;
|
|
513
|
+
}
|
|
514
|
+
} catch {
|
|
515
|
+
// Class escaping failed
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const parent = current.parentElement;
|
|
520
|
+
if (parent) {
|
|
521
|
+
const siblings = Array.from(parent.children).filter(
|
|
522
|
+
(s) => s.tagName === current!.tagName
|
|
523
|
+
);
|
|
524
|
+
if (siblings.length > 1) {
|
|
525
|
+
const index = siblings.indexOf(current) + 1;
|
|
526
|
+
selector += `:nth-of-type(${index})`;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
parts.unshift(selector);
|
|
531
|
+
current = current.parentElement;
|
|
532
|
+
|
|
533
|
+
if (parts.length >= 4) break;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return parts.join(' > ');
|
|
537
|
+
} catch {
|
|
538
|
+
return generateSimpleSelector(element);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Generate a simple fallback selector
|
|
544
|
+
*/
|
|
545
|
+
function generateSimpleSelector(element: Element): string {
|
|
546
|
+
const tag = element.tagName.toLowerCase();
|
|
547
|
+
const parent = element.parentElement;
|
|
548
|
+
|
|
549
|
+
if (!parent) return tag;
|
|
550
|
+
|
|
551
|
+
const siblings = Array.from(parent.children).filter(
|
|
552
|
+
(s) => s.tagName === element.tagName
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
if (siblings.length === 1) return tag;
|
|
556
|
+
|
|
557
|
+
const index = siblings.indexOf(element) + 1;
|
|
558
|
+
return `${tag}:nth-of-type(${index})`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Semantic HTML tags worth preserving in xpath
|
|
562
|
+
const SEMANTIC_TAGS = new Set([
|
|
563
|
+
'main', 'nav', 'header', 'footer', 'article', 'section', 'aside',
|
|
564
|
+
'form', 'table', 'ul', 'ol', 'li', 'dialog', 'menu',
|
|
565
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
566
|
+
'a', 'button', 'input', 'select', 'textarea', 'label',
|
|
567
|
+
'figure', 'figcaption', 'details', 'summary'
|
|
568
|
+
]);
|
|
569
|
+
|
|
570
|
+
// Class name patterns that are semantically meaningful
|
|
571
|
+
const SEMANTIC_CLASS_PATTERNS = [
|
|
572
|
+
/^(nav|menu|header|footer|sidebar|content|main|search|login|signup|cart|modal|dialog)/i,
|
|
573
|
+
/^(btn|button|link|tab|card|list|item|form|input|field)/i,
|
|
574
|
+
/^(primary|secondary|active|selected|disabled|error|success|warning)/i,
|
|
575
|
+
/^(container|wrapper|row|col|grid)$/i,
|
|
576
|
+
];
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Check if a class name is semantically meaningful
|
|
580
|
+
*/
|
|
581
|
+
function isSemanticClass(className: string): boolean {
|
|
582
|
+
// Skip utility classes (too short, or common CSS framework classes)
|
|
583
|
+
if (className.length < 3 || className.length > 25) return false;
|
|
584
|
+
if (/^[a-z]-/.test(className)) return false; // Tailwind-like single letter prefix
|
|
585
|
+
if (/^(mt|mb|ml|mr|mx|my|pt|pb|pl|pr|px|py|w-|h-|flex|grid|text-|bg-|border)/i.test(className)) return false;
|
|
586
|
+
|
|
587
|
+
return SEMANTIC_CLASS_PATTERNS.some(pattern => pattern.test(className));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Get the best semantic class from an element
|
|
592
|
+
*/
|
|
593
|
+
function getSemanticClass(element: Element): string | null {
|
|
594
|
+
if (!element.className || typeof element.className !== 'string') return null;
|
|
595
|
+
|
|
596
|
+
const classes = element.className.trim().split(/\s+/).filter(c => c.length > 0);
|
|
597
|
+
const semantic = classes.find(c => isSemanticClass(c));
|
|
598
|
+
|
|
599
|
+
return semantic || null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Build a semantic xpath for an element
|
|
604
|
+
* Format: /body/main#content/nav.primary/ul/li[2]/a.nav-link
|
|
605
|
+
*/
|
|
606
|
+
function buildSemanticXPath(element: Element): string {
|
|
607
|
+
const parts: string[] = [];
|
|
608
|
+
let current: Element | null = element;
|
|
609
|
+
const body = element.ownerDocument.body;
|
|
610
|
+
|
|
611
|
+
while (current && current !== body && current.parentElement) {
|
|
612
|
+
const tag = current.tagName.toLowerCase();
|
|
613
|
+
const id = current.id;
|
|
614
|
+
const semanticClass = getSemanticClass(current);
|
|
615
|
+
const isSemanticTag = SEMANTIC_TAGS.has(tag);
|
|
616
|
+
|
|
617
|
+
// Build the segment
|
|
618
|
+
let segment = '';
|
|
619
|
+
|
|
620
|
+
// Always include semantic tags, skip generic div/span unless they have id/class
|
|
621
|
+
if (isSemanticTag || id || semanticClass) {
|
|
622
|
+
segment = tag;
|
|
623
|
+
|
|
624
|
+
// Add id if present (most specific)
|
|
625
|
+
if (id && id.length < 30 && !/^\d/.test(id) && !/[^a-zA-Z0-9_-]/.test(id)) {
|
|
626
|
+
segment += `#${id}`;
|
|
627
|
+
}
|
|
628
|
+
// Add semantic class if no id
|
|
629
|
+
else if (semanticClass) {
|
|
630
|
+
segment += `.${semanticClass}`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Add index if there are siblings with same tag
|
|
634
|
+
const parent = current.parentElement;
|
|
635
|
+
if (parent) {
|
|
636
|
+
const siblings = Array.from(parent.children).filter(s => s.tagName === current!.tagName);
|
|
637
|
+
if (siblings.length > 1) {
|
|
638
|
+
const index = siblings.indexOf(current) + 1;
|
|
639
|
+
segment += `[${index}]`;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
parts.unshift(segment);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
current = current.parentElement;
|
|
647
|
+
|
|
648
|
+
// Limit depth to keep xpath readable
|
|
649
|
+
if (parts.length >= 6) break;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Always start with body for context
|
|
653
|
+
if (parts.length === 0) {
|
|
654
|
+
return '/' + element.tagName.toLowerCase();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return '/' + parts.join('/');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Generate flat snapshot of the DOM
|
|
662
|
+
*/
|
|
663
|
+
export function createSnapshot(
|
|
664
|
+
document: Document,
|
|
665
|
+
refMap: RefMap,
|
|
666
|
+
options: SnapshotOptions = {}
|
|
667
|
+
): SnapshotData {
|
|
668
|
+
const {
|
|
669
|
+
root = document.body,
|
|
670
|
+
maxDepth = 50,
|
|
671
|
+
includeHidden = false,
|
|
672
|
+
interactive = true,
|
|
673
|
+
all = false,
|
|
674
|
+
format = 'tree',
|
|
675
|
+
grep: grepPattern
|
|
676
|
+
} = options;
|
|
677
|
+
|
|
678
|
+
// Fast path for HTML format - return raw body HTML without processing
|
|
679
|
+
if (format === 'html') {
|
|
680
|
+
const bodyHTML = document.body?.outerHTML || '';
|
|
681
|
+
|
|
682
|
+
return {
|
|
683
|
+
tree: bodyHTML,
|
|
684
|
+
refs: {},
|
|
685
|
+
metadata: {
|
|
686
|
+
totalInteractiveElements: 0,
|
|
687
|
+
capturedElements: 0,
|
|
688
|
+
quality: 'high',
|
|
689
|
+
warnings: ['Raw HTML format - no filtering or ref generation applied']
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
refMap.clear();
|
|
695
|
+
|
|
696
|
+
const win = document.defaultView || window;
|
|
697
|
+
const refs: SnapshotData['refs'] = {};
|
|
698
|
+
const lines: string[] = [];
|
|
699
|
+
let refCounter = 0;
|
|
700
|
+
|
|
701
|
+
// Collect all elements
|
|
702
|
+
const elements: Element[] = [];
|
|
703
|
+
|
|
704
|
+
function collectElements(element: Element, depth: number): void {
|
|
705
|
+
if (depth > maxDepth) return;
|
|
706
|
+
// Only check element-level visibility, not ancestors (we're already traversing the tree)
|
|
707
|
+
if (!includeHidden && !isVisible(element, false)) return;
|
|
708
|
+
|
|
709
|
+
elements.push(element);
|
|
710
|
+
|
|
711
|
+
for (const child of element.children) {
|
|
712
|
+
collectElements(child, depth + 1);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
collectElements(root, 0);
|
|
717
|
+
|
|
718
|
+
// Filter and process elements
|
|
719
|
+
let totalInteractive = 0;
|
|
720
|
+
let capturedInteractive = 0;
|
|
721
|
+
|
|
722
|
+
for (const element of elements) {
|
|
723
|
+
const role = getRole(element);
|
|
724
|
+
const isInteractiveElement = isInteractive(element);
|
|
725
|
+
|
|
726
|
+
if (isInteractiveElement) totalInteractive++;
|
|
727
|
+
|
|
728
|
+
// Skip non-interactive in interactive mode
|
|
729
|
+
if (interactive && !isInteractiveElement) continue;
|
|
730
|
+
|
|
731
|
+
// Skip elements without role in non-all mode
|
|
732
|
+
if (!all && !role) continue;
|
|
733
|
+
|
|
734
|
+
const name = getAccessibleName(element);
|
|
735
|
+
|
|
736
|
+
// Build line
|
|
737
|
+
let line = '';
|
|
738
|
+
|
|
739
|
+
if (role) {
|
|
740
|
+
const roleUpper = role.toUpperCase();
|
|
741
|
+
line = roleUpper;
|
|
742
|
+
|
|
743
|
+
if (name) {
|
|
744
|
+
line += ` "${truncateByType(name, 'ELEMENT_NAME')}"`;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Generate ref for interactive elements
|
|
748
|
+
if (isInteractiveElement) {
|
|
749
|
+
const ref = `@ref:${refCounter++}`;
|
|
750
|
+
refMap.set(ref, element);
|
|
751
|
+
line += ` ${ref}`;
|
|
752
|
+
capturedInteractive++;
|
|
753
|
+
|
|
754
|
+
try {
|
|
755
|
+
const bbox = element.getBoundingClientRect();
|
|
756
|
+
refs[ref] = {
|
|
757
|
+
selector: generateSelector(element),
|
|
758
|
+
role: role.split(' ')[0],
|
|
759
|
+
name: name || undefined,
|
|
760
|
+
bbox: {
|
|
761
|
+
x: Math.round(bbox.x),
|
|
762
|
+
y: Math.round(bbox.y),
|
|
763
|
+
width: Math.round(bbox.width),
|
|
764
|
+
height: Math.round(bbox.height)
|
|
765
|
+
},
|
|
766
|
+
inViewport: isInViewport(element, win)
|
|
767
|
+
};
|
|
768
|
+
} catch {
|
|
769
|
+
refs[ref] = {
|
|
770
|
+
selector: generateSimpleSelector(element),
|
|
771
|
+
role: role.split(' ')[0],
|
|
772
|
+
name: name || undefined
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Add input attributes
|
|
778
|
+
line += getInputAttributes(element);
|
|
779
|
+
|
|
780
|
+
// Add state info
|
|
781
|
+
const states: string[] = [];
|
|
782
|
+
if (element.hasAttribute('disabled')) states.push('disabled');
|
|
783
|
+
if ((element as HTMLInputElement).checked) states.push('checked');
|
|
784
|
+
if (element.getAttribute('aria-expanded') === 'true') states.push('expanded');
|
|
785
|
+
if (element.getAttribute('aria-selected') === 'true') states.push('selected');
|
|
786
|
+
|
|
787
|
+
if (states.length) line += ` (${states.join(', ')})`;
|
|
788
|
+
|
|
789
|
+
// Add semantic xpath
|
|
790
|
+
const xpath = buildSemanticXPath(element);
|
|
791
|
+
line += ` ${xpath}`;
|
|
792
|
+
|
|
793
|
+
lines.push(line);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Build header
|
|
798
|
+
const pageHeader = `PAGE: ${document.location?.href || 'about:blank'} | ${document.title || 'Untitled'} | viewport=${win.innerWidth}x${win.innerHeight}`;
|
|
799
|
+
|
|
800
|
+
// Apply grep filter if specified (supports Unix grep options)
|
|
801
|
+
let filteredLines = lines;
|
|
802
|
+
let grepDisplayPattern = '';
|
|
803
|
+
|
|
804
|
+
if (grepPattern) {
|
|
805
|
+
// Parse grep options
|
|
806
|
+
const grepOpts = typeof grepPattern === 'string'
|
|
807
|
+
? { pattern: grepPattern }
|
|
808
|
+
: grepPattern;
|
|
809
|
+
|
|
810
|
+
const { pattern, ignoreCase = false, invert = false, fixedStrings = false } = grepOpts;
|
|
811
|
+
grepDisplayPattern = pattern;
|
|
812
|
+
|
|
813
|
+
// Build regex
|
|
814
|
+
let regexPattern = fixedStrings
|
|
815
|
+
? pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape regex chars
|
|
816
|
+
: pattern;
|
|
817
|
+
|
|
818
|
+
const flags = ignoreCase ? 'i' : '';
|
|
819
|
+
|
|
820
|
+
try {
|
|
821
|
+
const regex = new RegExp(regexPattern, flags);
|
|
822
|
+
filteredLines = lines.filter(line => {
|
|
823
|
+
const matches = regex.test(line);
|
|
824
|
+
return invert ? !matches : matches;
|
|
825
|
+
});
|
|
826
|
+
} catch {
|
|
827
|
+
// Invalid regex, fall back to string matching
|
|
828
|
+
filteredLines = lines.filter(line => {
|
|
829
|
+
const matches = ignoreCase
|
|
830
|
+
? line.toLowerCase().includes(pattern.toLowerCase())
|
|
831
|
+
: line.includes(pattern);
|
|
832
|
+
return invert ? !matches : matches;
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Build snapshot header with grep info if applicable
|
|
838
|
+
let snapshotHeader = `SNAPSHOT: elements=${elements.length} refs=${capturedInteractive}`;
|
|
839
|
+
if (grepPattern) {
|
|
840
|
+
snapshotHeader += ` grep=${grepDisplayPattern} matches=${filteredLines.length}`;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const output = [pageHeader, snapshotHeader, '', ...filteredLines].join('\n');
|
|
844
|
+
|
|
845
|
+
// Detect problematic page states
|
|
846
|
+
const warnings: string[] = [];
|
|
847
|
+
const viewportArea = win.innerWidth * win.innerHeight;
|
|
848
|
+
|
|
849
|
+
if (viewportArea === 0) {
|
|
850
|
+
warnings.push('Viewport not initialized (0x0) - page may be loading or redirecting');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (capturedInteractive === 0 && totalInteractive === 0 && elements.length < 10) {
|
|
854
|
+
warnings.push('Page appears to be empty or transitional - wait for content to load');
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (document.location?.href.includes('RotateCookies') ||
|
|
858
|
+
document.location?.href.includes('ServiceLogin') ||
|
|
859
|
+
document.location?.href.includes('/blank')) {
|
|
860
|
+
warnings.push('Detected intermediate/redirect page - snapshot may not contain meaningful content');
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return {
|
|
864
|
+
tree: output,
|
|
865
|
+
refs,
|
|
866
|
+
metadata: {
|
|
867
|
+
totalInteractiveElements: totalInteractive,
|
|
868
|
+
capturedElements: capturedInteractive,
|
|
869
|
+
quality: viewportArea === 0 || capturedInteractive === 0 ? 'low' : capturedInteractive < totalInteractive * 0.5 ? 'medium' : 'high',
|
|
870
|
+
warnings: warnings.length > 0 ? warnings : undefined
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
}
|