@dyyz1993/agent-browser 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/context.d.ts +4 -0
- package/dist/actions/context.d.ts.map +1 -1
- package/dist/actions/context.js +41 -1
- package/dist/actions/context.js.map +1 -1
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +164 -297
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/interaction.d.ts.map +1 -1
- package/dist/actions/interaction.js +96 -30
- package/dist/actions/interaction.js.map +1 -1
- package/dist/actions/touch.d.ts +25 -0
- package/dist/actions/touch.d.ts.map +1 -0
- package/dist/actions/touch.js +114 -0
- package/dist/actions/touch.js.map +1 -0
- package/dist/browser/browser-manager.d.ts +15 -0
- package/dist/browser/browser-manager.d.ts.map +1 -1
- package/dist/browser/browser-manager.js +75 -7
- package/dist/browser/browser-manager.js.map +1 -1
- package/dist/browser/network-analysis.d.ts +65 -0
- package/dist/browser/network-analysis.d.ts.map +1 -0
- package/dist/browser/network-analysis.js +359 -0
- package/dist/browser/network-analysis.js.map +1 -0
- package/dist/browser/network-tracker.d.ts +4 -0
- package/dist/browser/network-tracker.d.ts.map +1 -1
- package/dist/browser/network-tracker.js +41 -0
- package/dist/browser/network-tracker.js.map +1 -1
- package/dist/browser/popup-detector.d.ts +22 -0
- package/dist/browser/popup-detector.d.ts.map +1 -0
- package/dist/browser/popup-detector.js +138 -0
- package/dist/browser/popup-detector.js.map +1 -0
- package/dist/cli/commands/index.d.ts +5 -0
- package/dist/cli/commands/index.d.ts.map +1 -0
- package/dist/cli/commands/index.js +219 -0
- package/dist/cli/commands/index.js.map +1 -0
- package/dist/cli/commands/interact.d.ts +7 -0
- package/dist/cli/commands/interact.d.ts.map +1 -0
- package/dist/cli/commands/interact.js +371 -0
- package/dist/cli/commands/interact.js.map +1 -0
- package/dist/cli/commands/navigate.d.ts +4 -0
- package/dist/cli/commands/navigate.d.ts.map +1 -0
- package/dist/cli/commands/navigate.js +46 -0
- package/dist/cli/commands/navigate.js.map +1 -0
- package/dist/cli/commands/network.d.ts +3 -0
- package/dist/cli/commands/network.d.ts.map +1 -0
- package/dist/cli/commands/network.js +292 -0
- package/dist/cli/commands/network.js.map +1 -0
- package/dist/cli/commands/plugin.d.ts +3 -0
- package/dist/cli/commands/plugin.d.ts.map +1 -0
- package/dist/cli/commands/plugin.js +84 -0
- package/dist/cli/commands/plugin.js.map +1 -0
- package/dist/cli/commands/query.d.ts +7 -0
- package/dist/cli/commands/query.d.ts.map +1 -0
- package/dist/cli/commands/query.js +333 -0
- package/dist/cli/commands/query.js.map +1 -0
- package/dist/cli/commands/session.d.ts +3 -0
- package/dist/cli/commands/session.d.ts.map +1 -0
- package/dist/cli/commands/session.js +372 -0
- package/dist/cli/commands/session.js.map +1 -0
- package/dist/cli/commands/shared.d.ts +24 -0
- package/dist/cli/commands/shared.d.ts.map +1 -0
- package/dist/cli/commands/shared.js +113 -0
- package/dist/cli/commands/shared.js.map +1 -0
- package/dist/cli/commands.d.ts +1 -7
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +1 -1684
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/connection.d.ts.map +1 -1
- package/dist/cli/connection.js +9 -1
- package/dist/cli/connection.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +1 -24
- package/dist/cli/help.js.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +21 -1
- package/dist/daemon.js.map +1 -1
- package/dist/diff.d.ts +4 -0
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +16 -0
- package/dist/diff.js.map +1 -1
- package/dist/flow/exporters/cypress.js +1 -1
- package/dist/flow/exporters/cypress.js.map +1 -1
- package/dist/flow/exporters/selenium.js +1 -1
- package/dist/flow/exporters/selenium.js.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +2 -1
- package/dist/openapi.js.map +1 -1
- package/dist/plugins/registry.d.ts.map +1 -1
- package/dist/plugins/registry.js +4 -1
- package/dist/plugins/registry.js.map +1 -1
- package/dist/plugins/types.d.ts +4 -4
- package/dist/plugins/types.d.ts.map +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +30 -7
- package/dist/protocol.js.map +1 -1
- package/dist/snapshot/constants.d.ts +6 -0
- package/dist/snapshot/constants.d.ts.map +1 -0
- package/dist/snapshot/constants.js +77 -0
- package/dist/snapshot/constants.js.map +1 -0
- package/dist/snapshot/dom-scripts.d.ts +12 -0
- package/dist/snapshot/dom-scripts.d.ts.map +1 -0
- package/dist/snapshot/dom-scripts.js +438 -0
- package/dist/snapshot/dom-scripts.js.map +1 -0
- package/dist/snapshot/format.d.ts +13 -0
- package/dist/snapshot/format.d.ts.map +1 -0
- package/dist/snapshot/format.js +175 -0
- package/dist/snapshot/format.js.map +1 -0
- package/dist/snapshot/index.d.ts +6 -0
- package/dist/snapshot/index.d.ts.map +1 -0
- package/dist/snapshot/index.js +5 -0
- package/dist/snapshot/index.js.map +1 -0
- package/dist/snapshot/refs.d.ts +3 -0
- package/dist/snapshot/refs.d.ts.map +1 -0
- package/dist/snapshot/refs.js +8 -0
- package/dist/snapshot/refs.js.map +1 -0
- package/dist/snapshot/selectors.d.ts +17 -0
- package/dist/snapshot/selectors.d.ts.map +1 -0
- package/dist/snapshot/selectors.js +619 -0
- package/dist/snapshot/selectors.js.map +1 -0
- package/dist/snapshot/snapshot.d.ts +12 -0
- package/dist/snapshot/snapshot.d.ts.map +1 -0
- package/dist/snapshot/snapshot.js +104 -0
- package/dist/snapshot/snapshot.js.map +1 -0
- package/dist/snapshot/types.d.ts +27 -0
- package/dist/snapshot/types.d.ts.map +1 -0
- package/dist/snapshot/types.js +2 -0
- package/dist/snapshot/types.js.map +1 -0
- package/dist/snapshot.d.ts +1 -79
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/snapshot.js +1 -1800
- package/dist/snapshot.js.map +1 -1
- package/dist/stream/client-state.d.ts +13 -0
- package/dist/stream/client-state.d.ts.map +1 -0
- package/dist/stream/client-state.js +2 -0
- package/dist/stream/client-state.js.map +1 -0
- package/dist/stream/element-utils.d.ts +8 -0
- package/dist/stream/element-utils.d.ts.map +1 -0
- package/dist/stream/element-utils.js +25 -0
- package/dist/stream/element-utils.js.map +1 -0
- package/dist/stream/frame-processor.d.ts +63 -0
- package/dist/stream/frame-processor.d.ts.map +1 -0
- package/dist/stream/frame-processor.js +178 -0
- package/dist/stream/frame-processor.js.map +1 -0
- package/dist/stream/index.d.ts +10 -0
- package/dist/stream/index.d.ts.map +1 -0
- package/dist/stream/index.js +5 -0
- package/dist/stream/index.js.map +1 -0
- package/dist/stream/input-handler.d.ts +10 -0
- package/dist/stream/input-handler.d.ts.map +1 -0
- package/dist/stream/input-handler.js +81 -0
- package/dist/stream/input-handler.js.map +1 -0
- package/dist/stream/messages.d.ts +144 -0
- package/dist/stream/messages.d.ts.map +1 -0
- package/dist/stream/messages.js +46 -0
- package/dist/stream/messages.js.map +1 -0
- package/dist/stream-server-standalone.d.ts +0 -3
- package/dist/stream-server-standalone.d.ts.map +1 -1
- package/dist/stream-server-standalone.js +22 -82
- package/dist/stream-server-standalone.js.map +1 -1
- package/dist/stream-server.d.ts +8 -212
- package/dist/stream-server.d.ts.map +1 -1
- package/dist/stream-server.js +35 -389
- package/dist/stream-server.js.map +1 -1
- package/dist/types/base.d.ts +11 -0
- package/dist/types/base.d.ts.map +1 -0
- package/dist/types/base.js +2 -0
- package/dist/types/base.js.map +1 -0
- package/dist/types/browser.d.ts +26 -0
- package/dist/types/browser.d.ts.map +1 -0
- package/dist/types/browser.js +2 -0
- package/dist/types/browser.js.map +1 -0
- package/dist/types/commands.d.ts +768 -0
- package/dist/types/commands.d.ts.map +1 -0
- package/dist/types/commands.js +2 -0
- package/dist/types/commands.js.map +1 -0
- package/dist/types/crawl.d.ts +89 -0
- package/dist/types/crawl.d.ts.map +1 -0
- package/dist/types/crawl.js +2 -0
- package/dist/types/crawl.js.map +1 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +9 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/interact.d.ts +61 -0
- package/dist/types/interact.d.ts.map +1 -0
- package/dist/types/interact.js +2 -0
- package/dist/types/interact.js.map +1 -0
- package/dist/types/plugins.d.ts +39 -0
- package/dist/types/plugins.d.ts.map +1 -0
- package/dist/types/plugins.js +2 -0
- package/dist/types/plugins.js.map +1 -0
- package/dist/types/responses.d.ts +140 -0
- package/dist/types/responses.d.ts.map +1 -0
- package/dist/types/responses.js +4 -0
- package/dist/types/responses.js.map +1 -0
- package/dist/types/utils.d.ts +12 -0
- package/dist/types/utils.d.ts.map +1 -0
- package/dist/types/utils.js +2 -0
- package/dist/types/utils.js.map +1 -0
- package/dist/types.d.ts +1 -1125
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -3
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +31 -0
- package/dist/version.js.map +1 -0
- package/package.json +11 -11
package/dist/snapshot.js
CHANGED
|
@@ -1,1801 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
* Enhanced snapshot with element refs for deterministic element selection.
|
|
3
|
-
*
|
|
4
|
-
* This module generates accessibility snapshots with embedded refs that can be
|
|
5
|
-
* used to click/fill/interact with elements without re-querying the DOM.
|
|
6
|
-
*
|
|
7
|
-
* Example output:
|
|
8
|
-
* - heading "Example Domain" [ref=e1] [level=1]
|
|
9
|
-
* - paragraph: Some text content
|
|
10
|
-
* - button "Submit" [ref=e2]
|
|
11
|
-
* - textbox "Email" [ref=e3]
|
|
12
|
-
*
|
|
13
|
-
* Usage:
|
|
14
|
-
* agent-browser snapshot # Full snapshot
|
|
15
|
-
* agent-browser snapshot -i # Interactive elements only
|
|
16
|
-
* agent-browser snapshot --depth 3 # Limit depth
|
|
17
|
-
* agent-browser click @e2 # Click element by ref
|
|
18
|
-
*/
|
|
19
|
-
// Counter for generating refs
|
|
20
|
-
let refCounter = 0;
|
|
21
|
-
/**
|
|
22
|
-
* Reset ref counter (call at start of each snapshot)
|
|
23
|
-
*/
|
|
24
|
-
export function resetRefs() {
|
|
25
|
-
refCounter = 0;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Generate next ref ID
|
|
29
|
-
*/
|
|
30
|
-
function nextRef() {
|
|
31
|
-
return `e${++refCounter}`;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Roles that are interactive and should get refs
|
|
35
|
-
*/
|
|
36
|
-
const INTERACTIVE_ROLES = new Set([
|
|
37
|
-
'button',
|
|
38
|
-
'link',
|
|
39
|
-
'textbox',
|
|
40
|
-
'checkbox',
|
|
41
|
-
'radio',
|
|
42
|
-
'combobox',
|
|
43
|
-
'listbox',
|
|
44
|
-
'menuitem',
|
|
45
|
-
'menuitemcheckbox',
|
|
46
|
-
'menuitemradio',
|
|
47
|
-
'option',
|
|
48
|
-
'searchbox',
|
|
49
|
-
'slider',
|
|
50
|
-
'spinbutton',
|
|
51
|
-
'switch',
|
|
52
|
-
'tab',
|
|
53
|
-
'treeitem',
|
|
54
|
-
]);
|
|
55
|
-
/**
|
|
56
|
-
* Roles that provide structure/context (get refs for text extraction)
|
|
57
|
-
*/
|
|
58
|
-
const CONTENT_ROLES = new Set([
|
|
59
|
-
'heading',
|
|
60
|
-
'cell',
|
|
61
|
-
'gridcell',
|
|
62
|
-
'columnheader',
|
|
63
|
-
'rowheader',
|
|
64
|
-
'listitem',
|
|
65
|
-
'article',
|
|
66
|
-
'region',
|
|
67
|
-
'main',
|
|
68
|
-
'navigation',
|
|
69
|
-
]);
|
|
70
|
-
/**
|
|
71
|
-
* Roles that are purely structural (can be filtered in compact mode)
|
|
72
|
-
*/
|
|
73
|
-
const STRUCTURAL_ROLES = new Set([
|
|
74
|
-
'generic',
|
|
75
|
-
'group',
|
|
76
|
-
'list',
|
|
77
|
-
'table',
|
|
78
|
-
'row',
|
|
79
|
-
'rowgroup',
|
|
80
|
-
'grid',
|
|
81
|
-
'treegrid',
|
|
82
|
-
'menu',
|
|
83
|
-
'menubar',
|
|
84
|
-
'toolbar',
|
|
85
|
-
'tablist',
|
|
86
|
-
'tree',
|
|
87
|
-
'directory',
|
|
88
|
-
'document',
|
|
89
|
-
'application',
|
|
90
|
-
'presentation',
|
|
91
|
-
'none',
|
|
92
|
-
]);
|
|
93
|
-
/**
|
|
94
|
-
* Build a selector string for storing in ref map
|
|
95
|
-
*/
|
|
96
|
-
function buildSelector(role, name) {
|
|
97
|
-
if (name) {
|
|
98
|
-
const escapedName = name.replace(/"/g, '\\"');
|
|
99
|
-
return `getByRole('${role}', { name: "${escapedName}", exact: true })`;
|
|
100
|
-
}
|
|
101
|
-
return `getByRole('${role}')`;
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* Query the page for clickable elements that might not have proper ARIA roles.
|
|
105
|
-
* This finds elements with cursor: pointer or onclick handlers.
|
|
106
|
-
*/
|
|
107
|
-
async function findCursorInteractiveElements(page, selector) {
|
|
108
|
-
const rootSelector = selector || 'body';
|
|
109
|
-
// Use a string function body to avoid TypeScript transpilation issues
|
|
110
|
-
const scriptBody = `(rootSel) => {
|
|
111
|
-
const results = [];
|
|
112
|
-
|
|
113
|
-
// Elements that already have interactive ARIA roles - skip these
|
|
114
|
-
const interactiveRoles = new Set([
|
|
115
|
-
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox',
|
|
116
|
-
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'searchbox',
|
|
117
|
-
'slider', 'spinbutton', 'switch', 'tab', 'treeitem'
|
|
118
|
-
]);
|
|
119
|
-
|
|
120
|
-
// Tags that are already interactive by default
|
|
121
|
-
const interactiveTags = new Set([
|
|
122
|
-
'a', 'button', 'input', 'select', 'textarea', 'details', 'summary'
|
|
123
|
-
]);
|
|
124
|
-
|
|
125
|
-
const root = document.querySelector(rootSel) || document.body;
|
|
126
|
-
const allElements = root.querySelectorAll('*');
|
|
127
|
-
|
|
128
|
-
// Build a unique selector for an element
|
|
129
|
-
const buildSelector = (el) => {
|
|
130
|
-
const testId = el.getAttribute('data-testid');
|
|
131
|
-
if (testId) return '[data-testid="' + testId + '"]';
|
|
132
|
-
if (el.id) return '#' + CSS.escape(el.id);
|
|
133
|
-
|
|
134
|
-
const path = [];
|
|
135
|
-
let current = el;
|
|
136
|
-
while (current && current !== document.body) {
|
|
137
|
-
let sel = current.tagName.toLowerCase();
|
|
138
|
-
const classes = Array.from(current.classList).filter(c => c.trim());
|
|
139
|
-
if (classes.length > 0) sel += '.' + CSS.escape(classes[0]);
|
|
140
|
-
|
|
141
|
-
const parent = current.parentElement;
|
|
142
|
-
if (parent) {
|
|
143
|
-
const siblings = Array.from(parent.children);
|
|
144
|
-
const matching = siblings.filter(s => {
|
|
145
|
-
if (s.tagName !== current.tagName) return false;
|
|
146
|
-
if (classes.length > 0 && !s.classList.contains(classes[0])) return false;
|
|
147
|
-
return true;
|
|
148
|
-
});
|
|
149
|
-
if (matching.length > 1) {
|
|
150
|
-
const idx = matching.indexOf(current) + 1;
|
|
151
|
-
sel += ':nth-of-type(' + idx + ')';
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
path.unshift(sel);
|
|
155
|
-
current = current.parentElement;
|
|
156
|
-
if (path.length >= 3) break;
|
|
157
|
-
}
|
|
158
|
-
return path.join(' > ');
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
// Heuristic patterns for clickable element class names
|
|
162
|
-
// Use substring matching (contains) for more flexible detection
|
|
163
|
-
const clickableClassPatterns = [
|
|
164
|
-
/tab/i, // matches: tab, tabs, tab-item, creator-tab, tablist
|
|
165
|
-
/btn/i, // matches: btn, button, btn-primary, my-btn
|
|
166
|
-
/button/i, // matches: button, button-group, my-button
|
|
167
|
-
/clickable/i, // matches: clickable, is-clickable, clickable-area
|
|
168
|
-
/action/i, // matches: action, actions, action-btn, user-action
|
|
169
|
-
/menuitem/i, // matches: menuitem, menu-item, my-menuitem
|
|
170
|
-
/navitem/i, // matches: navitem, nav-item, navbar-item
|
|
171
|
-
/listitem/i, // matches: listitem, list-item, my-listitem
|
|
172
|
-
/card/i, // matches: card, card-item, profile-card
|
|
173
|
-
/toggle/i, // matches: toggle, toggle-btn, dark-toggle
|
|
174
|
-
/switch/i, // matches: switch, switch-btn, toggle-switch
|
|
175
|
-
/dropdown/i, // matches: dropdown, dropdown-menu, my-dropdown
|
|
176
|
-
/modal/i, // matches: modal, modal-trigger, modal-open
|
|
177
|
-
/popup/i, // matches: popup, popup-trigger, show-popup
|
|
178
|
-
/close/i, // matches: close, close-btn, modal-close
|
|
179
|
-
/dismiss/i, // matches: dismiss, dismiss-btn
|
|
180
|
-
/expand/i, // matches: expand, expand-btn, expandable
|
|
181
|
-
/collapse/i, // matches: collapse, collapse-btn, collapsible
|
|
182
|
-
];
|
|
183
|
-
|
|
184
|
-
// Check if element looks clickable based on class name
|
|
185
|
-
const hasClickableClassName = (el) => {
|
|
186
|
-
const className = el.getAttribute('class') || '';
|
|
187
|
-
// Split by whitespace and check each individual class
|
|
188
|
-
const classes = className.split(/\s+/);
|
|
189
|
-
return classes.some(cls => clickableClassPatterns.some(p => p.test(cls)));
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
// Check for Vue/React event handlers (data attributes or special patterns)
|
|
193
|
-
const hasFrameworkEventHandler = (el) => {
|
|
194
|
-
// Vue: elements with @click often have data-v-* attributes and are in components
|
|
195
|
-
// Check for common Vue/React patterns
|
|
196
|
-
const attrs = el.attributes;
|
|
197
|
-
for (let i = 0; i < attrs.length; i++) {
|
|
198
|
-
const name = attrs[i].name;
|
|
199
|
-
// Vue compiled @click handlers
|
|
200
|
-
if (name.startsWith('data-v-')) return true;
|
|
201
|
-
// React synthetic events sometimes leave traces
|
|
202
|
-
if (name.startsWith('data-reactid')) return true;
|
|
203
|
-
}
|
|
204
|
-
return false;
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
for (const el of allElements) {
|
|
208
|
-
const tagName = el.tagName.toLowerCase();
|
|
209
|
-
if (interactiveTags.has(tagName)) continue;
|
|
210
|
-
|
|
211
|
-
const role = el.getAttribute('role');
|
|
212
|
-
if (role && interactiveRoles.has(role.toLowerCase())) continue;
|
|
213
|
-
|
|
214
|
-
const computedStyle = getComputedStyle(el);
|
|
215
|
-
const hasCursorPointer = computedStyle.cursor === 'pointer';
|
|
216
|
-
const hasOnClick = el.hasAttribute('onclick') || el.onclick !== null;
|
|
217
|
-
const tabIndex = el.getAttribute('tabindex');
|
|
218
|
-
const hasTabIndex = tabIndex !== null && tabIndex !== '-1';
|
|
219
|
-
|
|
220
|
-
// New heuristic checks
|
|
221
|
-
const looksClickable = hasClickableClassName(el);
|
|
222
|
-
const hasFrameworkEvent = hasFrameworkEventHandler(el);
|
|
223
|
-
|
|
224
|
-
// Skip if no indication of interactivity
|
|
225
|
-
// Only include elements that look explicitly clickable (not just have framework events)
|
|
226
|
-
const isClickable = hasCursorPointer || hasOnClick || hasTabIndex || looksClickable;
|
|
227
|
-
if (!isClickable) continue;
|
|
228
|
-
|
|
229
|
-
// Get direct text content only (exclude child elements' text)
|
|
230
|
-
// This helps avoid duplicate entries from parent containers
|
|
231
|
-
let text = '';
|
|
232
|
-
for (const node of el.childNodes) {
|
|
233
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
234
|
-
text += node.textContent || '';
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
text = text.trim();
|
|
238
|
-
|
|
239
|
-
// If no direct text, check if element has only one child element (like a span)
|
|
240
|
-
// This handles cases like <div class="tab"><span>Tab Text</span></div>
|
|
241
|
-
if (!text && el.children.length === 1) {
|
|
242
|
-
const child = el.children[0];
|
|
243
|
-
// Check if child is a simple inline element with only text
|
|
244
|
-
if (child.tagName === 'SPAN' || child.tagName === 'A' || child.tagName === 'LABEL') {
|
|
245
|
-
text = (child.textContent || '').trim();
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Skip if no text
|
|
250
|
-
if (!text) continue;
|
|
251
|
-
|
|
252
|
-
// Skip if text is too long (likely a container with many children)
|
|
253
|
-
if (text.length > 30) continue;
|
|
254
|
-
|
|
255
|
-
const rect = el.getBoundingClientRect();
|
|
256
|
-
if (rect.width === 0 || rect.height === 0) continue;
|
|
257
|
-
|
|
258
|
-
// Determine if this is truly clickable or just has framework events
|
|
259
|
-
const trulyClickable = hasCursorPointer || hasOnClick || hasTabIndex || looksClickable;
|
|
260
|
-
|
|
261
|
-
results.push({
|
|
262
|
-
selector: buildSelector(el),
|
|
263
|
-
text,
|
|
264
|
-
tagName,
|
|
265
|
-
hasOnClick,
|
|
266
|
-
hasCursorPointer: trulyClickable,
|
|
267
|
-
hasTabIndex
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
return results;
|
|
271
|
-
}`;
|
|
272
|
-
const fn = new Function('return ' + scriptBody)();
|
|
273
|
-
return page.evaluate(fn, rootSelector);
|
|
274
|
-
}
|
|
275
|
-
/**
|
|
276
|
-
* Suggest common selectors for the current page
|
|
277
|
-
*/
|
|
278
|
-
async function suggestSelectors(page) {
|
|
279
|
-
const selectors = [];
|
|
280
|
-
try {
|
|
281
|
-
const commonSelectors = [
|
|
282
|
-
'body',
|
|
283
|
-
'main',
|
|
284
|
-
'#main',
|
|
285
|
-
'#content',
|
|
286
|
-
'.content',
|
|
287
|
-
'article',
|
|
288
|
-
'form',
|
|
289
|
-
'#app',
|
|
290
|
-
'.app',
|
|
291
|
-
];
|
|
292
|
-
for (const selector of commonSelectors) {
|
|
293
|
-
try {
|
|
294
|
-
const locator = page.locator(selector);
|
|
295
|
-
const count = await locator.count();
|
|
296
|
-
if (count > 0) {
|
|
297
|
-
selectors.push(selector);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
// Ignore errors
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
if (selectors.length === 0) {
|
|
305
|
-
selectors.push('body');
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
catch {
|
|
309
|
-
selectors.push('body');
|
|
310
|
-
}
|
|
311
|
-
return selectors;
|
|
312
|
-
}
|
|
313
|
-
export async function generateStableSelectors(page, refs) {
|
|
314
|
-
const result = {};
|
|
315
|
-
for (const [ref, data] of Object.entries(refs)) {
|
|
316
|
-
if (data.role === 'clickable' || data.role === 'focusable') {
|
|
317
|
-
if (data.selector && !data.selector.startsWith('getByRole')) {
|
|
318
|
-
result[ref] = { cssSelector: data.selector, xpath: '' };
|
|
319
|
-
}
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
|
-
try {
|
|
323
|
-
let locator;
|
|
324
|
-
if (data.name) {
|
|
325
|
-
locator = page.getByRole(data.role, {
|
|
326
|
-
name: data.name,
|
|
327
|
-
exact: true,
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
else {
|
|
331
|
-
locator = page.getByRole(data.role);
|
|
332
|
-
}
|
|
333
|
-
if (data.nth !== undefined) {
|
|
334
|
-
locator = locator.nth(data.nth);
|
|
335
|
-
}
|
|
336
|
-
const elementCount = await locator.count();
|
|
337
|
-
if (elementCount === 0)
|
|
338
|
-
continue;
|
|
339
|
-
const selectorData = await locator
|
|
340
|
-
.evaluate((el) => {
|
|
341
|
-
const UTILITY_CLASS_PATTERNS = [
|
|
342
|
-
/^_/,
|
|
343
|
-
/^css-/,
|
|
344
|
-
/^[a-z]{1,2}$/,
|
|
345
|
-
/^(active|disabled|hidden|visible|selected|hover|focus|current|open|closed)$/i,
|
|
346
|
-
/^(text-|font-|bg-|p-|m-|w-|h-|flex|grid|border|rounded|shadow|opacity|z-)/,
|
|
347
|
-
/^(sm:|md:|lg:|xl:|2xl:)/,
|
|
348
|
-
];
|
|
349
|
-
const SEMANTIC_ATTRS = [
|
|
350
|
-
'data-testid',
|
|
351
|
-
'data-test',
|
|
352
|
-
'data-cy',
|
|
353
|
-
'name',
|
|
354
|
-
'aria-label',
|
|
355
|
-
'aria-labelledby',
|
|
356
|
-
'role',
|
|
357
|
-
'type',
|
|
358
|
-
'placeholder',
|
|
359
|
-
'title',
|
|
360
|
-
'alt',
|
|
361
|
-
];
|
|
362
|
-
function isHighEntropyClassName(className) {
|
|
363
|
-
if (!className || className.length < 4 || className.length > 15)
|
|
364
|
-
return false;
|
|
365
|
-
if (/^[a-zA-Z]+_[a-zA-Z]+_{2}[a-zA-Z0-9]+$/.test(className))
|
|
366
|
-
return true;
|
|
367
|
-
if (/^sc-[a-zA-Z0-9]+$/.test(className))
|
|
368
|
-
return true;
|
|
369
|
-
const hasUpper = /[A-Z]/.test(className);
|
|
370
|
-
const hasLower = /[a-z]/.test(className);
|
|
371
|
-
const hasDigit = /[0-9]/.test(className);
|
|
372
|
-
const hasSeparator = /[-_]/.test(className);
|
|
373
|
-
if (hasSeparator)
|
|
374
|
-
return false;
|
|
375
|
-
if (hasUpper && hasLower && hasDigit)
|
|
376
|
-
return true;
|
|
377
|
-
if (/^[A-Z][a-z0-9]+[A-Z]/.test(className) && className.length <= 12)
|
|
378
|
-
return true;
|
|
379
|
-
if (/^[a-z]/.test(className) && /[a-z][A-Z][a-z][A-Z]/.test(className))
|
|
380
|
-
return true;
|
|
381
|
-
return false;
|
|
382
|
-
}
|
|
383
|
-
function isUniqueSelector(selector) {
|
|
384
|
-
try {
|
|
385
|
-
return document.querySelectorAll(selector).length === 1;
|
|
386
|
-
}
|
|
387
|
-
catch {
|
|
388
|
-
return false;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
function filterUsefulClasses(element) {
|
|
392
|
-
const htmlEl = element;
|
|
393
|
-
if (!htmlEl.className || typeof htmlEl.className !== 'string')
|
|
394
|
-
return [];
|
|
395
|
-
return htmlEl.className
|
|
396
|
-
.trim()
|
|
397
|
-
.split(/\s+/)
|
|
398
|
-
.filter((c) => {
|
|
399
|
-
if (!c)
|
|
400
|
-
return false;
|
|
401
|
-
if (UTILITY_CLASS_PATTERNS.some((p) => p.test(c)))
|
|
402
|
-
return false;
|
|
403
|
-
if (isHighEntropyClassName(c))
|
|
404
|
-
return false;
|
|
405
|
-
return true;
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
function tryIdSelector(element) {
|
|
409
|
-
const htmlEl = element;
|
|
410
|
-
if (htmlEl.id) {
|
|
411
|
-
const sel = '#' + CSS.escape(htmlEl.id);
|
|
412
|
-
if (isUniqueSelector(sel))
|
|
413
|
-
return sel;
|
|
414
|
-
}
|
|
415
|
-
return null;
|
|
416
|
-
}
|
|
417
|
-
function getMultiAttributeSelector(element) {
|
|
418
|
-
const tag = element.tagName.toLowerCase();
|
|
419
|
-
const attrs = [];
|
|
420
|
-
for (const attr of SEMANTIC_ATTRS) {
|
|
421
|
-
const value = element.getAttribute(attr);
|
|
422
|
-
if (value)
|
|
423
|
-
attrs.push({ attr, value });
|
|
424
|
-
}
|
|
425
|
-
if (attrs.length === 0)
|
|
426
|
-
return null;
|
|
427
|
-
for (const { attr, value } of attrs) {
|
|
428
|
-
const sel = tag + '[' + attr + '="' + CSS.escape(value) + '"]';
|
|
429
|
-
if (isUniqueSelector(sel))
|
|
430
|
-
return sel;
|
|
431
|
-
}
|
|
432
|
-
if (attrs.length >= 2) {
|
|
433
|
-
for (let i = 0; i < attrs.length; i++) {
|
|
434
|
-
for (let j = i + 1; j < attrs.length; j++) {
|
|
435
|
-
const sel = tag +
|
|
436
|
-
'[' +
|
|
437
|
-
attrs[i].attr +
|
|
438
|
-
'="' +
|
|
439
|
-
CSS.escape(attrs[i].value) +
|
|
440
|
-
'"]' +
|
|
441
|
-
'[' +
|
|
442
|
-
attrs[j].attr +
|
|
443
|
-
'="' +
|
|
444
|
-
CSS.escape(attrs[j].value) +
|
|
445
|
-
'"]';
|
|
446
|
-
if (isUniqueSelector(sel))
|
|
447
|
-
return sel;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
return null;
|
|
452
|
-
}
|
|
453
|
-
function getAttributeClassComboSelector(element) {
|
|
454
|
-
const tag = element.tagName.toLowerCase();
|
|
455
|
-
const classes = filterUsefulClasses(element);
|
|
456
|
-
if (classes.length === 0)
|
|
457
|
-
return null;
|
|
458
|
-
classes.sort((a, b) => b.length - a.length);
|
|
459
|
-
const bestClass = classes[0];
|
|
460
|
-
for (const attr of SEMANTIC_ATTRS) {
|
|
461
|
-
const value = element.getAttribute(attr);
|
|
462
|
-
if (value) {
|
|
463
|
-
const sel = tag + '.' + CSS.escape(bestClass) + '[' + attr + '="' + CSS.escape(value) + '"]';
|
|
464
|
-
if (isUniqueSelector(sel))
|
|
465
|
-
return sel;
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
return null;
|
|
469
|
-
}
|
|
470
|
-
function getBestClassSelector(element) {
|
|
471
|
-
const classes = filterUsefulClasses(element);
|
|
472
|
-
if (classes.length === 0)
|
|
473
|
-
return null;
|
|
474
|
-
classes.sort((a, b) => b.length - a.length);
|
|
475
|
-
const tag = element.tagName.toLowerCase();
|
|
476
|
-
for (const cls of classes) {
|
|
477
|
-
const sel = tag + '.' + CSS.escape(cls);
|
|
478
|
-
if (isUniqueSelector(sel))
|
|
479
|
-
return sel;
|
|
480
|
-
}
|
|
481
|
-
for (let i = 2; i <= Math.min(3, classes.length); i++) {
|
|
482
|
-
const sel = tag +
|
|
483
|
-
'.' +
|
|
484
|
-
classes
|
|
485
|
-
.slice(0, i)
|
|
486
|
-
.map((c) => CSS.escape(c))
|
|
487
|
-
.join('.');
|
|
488
|
-
if (isUniqueSelector(sel))
|
|
489
|
-
return sel;
|
|
490
|
-
}
|
|
491
|
-
return null;
|
|
492
|
-
}
|
|
493
|
-
function getFeatureSelector(element) {
|
|
494
|
-
if (!element || element === document.body)
|
|
495
|
-
return null;
|
|
496
|
-
const htmlEl = element;
|
|
497
|
-
if (htmlEl.id)
|
|
498
|
-
return '#' + CSS.escape(htmlEl.id);
|
|
499
|
-
for (const attr of ['data-testid', 'data-test', 'name', 'role', 'aria-label']) {
|
|
500
|
-
const value = element.getAttribute(attr);
|
|
501
|
-
if (value)
|
|
502
|
-
return element.tagName.toLowerCase() + '[' + attr + '="' + CSS.escape(value) + '"]';
|
|
503
|
-
}
|
|
504
|
-
const classes = filterUsefulClasses(element);
|
|
505
|
-
if (classes.length > 0) {
|
|
506
|
-
classes.sort((a, b) => b.length - a.length);
|
|
507
|
-
const sel = element.tagName.toLowerCase() + '.' + CSS.escape(classes[0]);
|
|
508
|
-
if (isUniqueSelector(sel))
|
|
509
|
-
return sel;
|
|
510
|
-
}
|
|
511
|
-
return null;
|
|
512
|
-
}
|
|
513
|
-
function getBaseSelector(element) {
|
|
514
|
-
let sel = element.tagName.toLowerCase();
|
|
515
|
-
const classes = filterUsefulClasses(element);
|
|
516
|
-
if (classes.length > 0) {
|
|
517
|
-
classes.sort((a, b) => b.length - a.length);
|
|
518
|
-
sel +=
|
|
519
|
-
'.' +
|
|
520
|
-
classes
|
|
521
|
-
.slice(0, 2)
|
|
522
|
-
.map((c) => CSS.escape(c))
|
|
523
|
-
.join('.');
|
|
524
|
-
}
|
|
525
|
-
return sel;
|
|
526
|
-
}
|
|
527
|
-
function makeUniqueWithNth(element, baseSelector) {
|
|
528
|
-
const parent = element.parentElement;
|
|
529
|
-
if (!parent)
|
|
530
|
-
return baseSelector;
|
|
531
|
-
const siblings = Array.from(parent.children);
|
|
532
|
-
const sameTagSiblings = siblings.filter((s) => s.tagName === element.tagName);
|
|
533
|
-
if (sameTagSiblings.length === 1)
|
|
534
|
-
return baseSelector;
|
|
535
|
-
const index = siblings.indexOf(element) + 1;
|
|
536
|
-
return baseSelector + ':nth-child(' + index + ')';
|
|
537
|
-
}
|
|
538
|
-
function getSiblingBasedSelector(element) {
|
|
539
|
-
let prevSibling = element.previousElementSibling;
|
|
540
|
-
let attempts = 0;
|
|
541
|
-
while (prevSibling && attempts < 3) {
|
|
542
|
-
const siblingSelector = getFeatureSelector(prevSibling);
|
|
543
|
-
if (siblingSelector && isUniqueSelector(siblingSelector)) {
|
|
544
|
-
const elementSelector = getBaseSelector(element);
|
|
545
|
-
const combined = siblingSelector + ' + ' + elementSelector;
|
|
546
|
-
if (isUniqueSelector(combined))
|
|
547
|
-
return combined;
|
|
548
|
-
}
|
|
549
|
-
prevSibling = prevSibling.previousElementSibling;
|
|
550
|
-
attempts++;
|
|
551
|
-
}
|
|
552
|
-
return null;
|
|
553
|
-
}
|
|
554
|
-
function buildComposedSelector(element) {
|
|
555
|
-
const selfSelector = getBestClassSelector(element);
|
|
556
|
-
if (selfSelector && isUniqueSelector(selfSelector))
|
|
557
|
-
return selfSelector;
|
|
558
|
-
const parts = [];
|
|
559
|
-
let current = element;
|
|
560
|
-
let depth = 0;
|
|
561
|
-
const maxDepth = 3;
|
|
562
|
-
while (current && current !== document.body && depth < maxDepth) {
|
|
563
|
-
const featureSelector = getFeatureSelector(current);
|
|
564
|
-
if (featureSelector) {
|
|
565
|
-
parts.unshift(featureSelector);
|
|
566
|
-
const elementSelector = depth === 0 ? getBaseSelector(element) : getBaseSelector(current);
|
|
567
|
-
const fullSelector = parts.join(' > ') + (depth > 0 ? '' : ' > ' + elementSelector);
|
|
568
|
-
if (isUniqueSelector(fullSelector))
|
|
569
|
-
return fullSelector;
|
|
570
|
-
}
|
|
571
|
-
else {
|
|
572
|
-
const baseSelector = getBaseSelector(current);
|
|
573
|
-
const selector = makeUniqueWithNth(current, baseSelector);
|
|
574
|
-
parts.unshift(selector);
|
|
575
|
-
const fullSelector = parts.join(' > ');
|
|
576
|
-
if (isUniqueSelector(fullSelector))
|
|
577
|
-
return fullSelector;
|
|
578
|
-
}
|
|
579
|
-
current = current.parentElement;
|
|
580
|
-
depth++;
|
|
581
|
-
}
|
|
582
|
-
return parts.length > 0 ? parts.join(' > ') : null;
|
|
583
|
-
}
|
|
584
|
-
function tryNthChild(element) {
|
|
585
|
-
const baseSelector = getBaseSelector(element);
|
|
586
|
-
const uniqueSelector = makeUniqueWithNth(element, baseSelector);
|
|
587
|
-
try {
|
|
588
|
-
if (document.querySelectorAll(uniqueSelector).length === 1)
|
|
589
|
-
return uniqueSelector;
|
|
590
|
-
}
|
|
591
|
-
catch {
|
|
592
|
-
// Intentionally ignored: invalid CSS selector in tryNthChild
|
|
593
|
-
}
|
|
594
|
-
return null;
|
|
595
|
-
}
|
|
596
|
-
function buildUniquePath(element) {
|
|
597
|
-
const parts = [];
|
|
598
|
-
let current = element;
|
|
599
|
-
let depth = 0;
|
|
600
|
-
while (current && current !== document.body && depth < 5) {
|
|
601
|
-
const baseSelector = getBaseSelector(current);
|
|
602
|
-
const selector = makeUniqueWithNth(current, baseSelector);
|
|
603
|
-
parts.unshift(selector);
|
|
604
|
-
const fullSelector = parts.join(' > ');
|
|
605
|
-
if (isUniqueSelector(fullSelector))
|
|
606
|
-
return fullSelector;
|
|
607
|
-
current = current.parentElement;
|
|
608
|
-
depth++;
|
|
609
|
-
}
|
|
610
|
-
return parts.length > 0 ? parts.join(' > ') : null;
|
|
611
|
-
}
|
|
612
|
-
function generateXPath(element) {
|
|
613
|
-
const htmlEl = element;
|
|
614
|
-
if (htmlEl.id)
|
|
615
|
-
return '//*[@id="' + htmlEl.id + '"]';
|
|
616
|
-
const testId = element.getAttribute('data-testid');
|
|
617
|
-
if (testId)
|
|
618
|
-
return '//*[@data-testid="' + testId + '"]';
|
|
619
|
-
const nameAttr = element.getAttribute('name');
|
|
620
|
-
if (nameAttr)
|
|
621
|
-
return '//' + element.tagName.toLowerCase() + '[@name="' + nameAttr + '"]';
|
|
622
|
-
const parts = [];
|
|
623
|
-
let current = element;
|
|
624
|
-
let depth = 0;
|
|
625
|
-
while (current && depth < 5) {
|
|
626
|
-
const curHtml = current;
|
|
627
|
-
if (curHtml.id) {
|
|
628
|
-
parts.unshift('//*[@id="' + curHtml.id + '"]');
|
|
629
|
-
break;
|
|
630
|
-
}
|
|
631
|
-
const testId = current.getAttribute('data-testid');
|
|
632
|
-
if (testId) {
|
|
633
|
-
parts.unshift('//*[@data-testid="' + testId + '"]');
|
|
634
|
-
break;
|
|
635
|
-
}
|
|
636
|
-
const tagName = current.tagName.toLowerCase();
|
|
637
|
-
const parent = current.parentElement;
|
|
638
|
-
if (parent) {
|
|
639
|
-
const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
|
|
640
|
-
const index = siblings.indexOf(current) + 1;
|
|
641
|
-
parts.unshift(tagName + '[' + index + ']');
|
|
642
|
-
}
|
|
643
|
-
else {
|
|
644
|
-
parts.unshift(tagName);
|
|
645
|
-
}
|
|
646
|
-
current = current.parentElement;
|
|
647
|
-
depth++;
|
|
648
|
-
}
|
|
649
|
-
if (parts.length > 0 && !parts[0].startsWith('//'))
|
|
650
|
-
parts.unshift('//');
|
|
651
|
-
return parts.join('/');
|
|
652
|
-
}
|
|
653
|
-
let cssSelector = null;
|
|
654
|
-
cssSelector = tryIdSelector(el);
|
|
655
|
-
if (!cssSelector)
|
|
656
|
-
cssSelector = getMultiAttributeSelector(el);
|
|
657
|
-
if (!cssSelector)
|
|
658
|
-
cssSelector = getAttributeClassComboSelector(el);
|
|
659
|
-
if (!cssSelector)
|
|
660
|
-
cssSelector = getBestClassSelector(el);
|
|
661
|
-
if (!cssSelector)
|
|
662
|
-
cssSelector = getSiblingBasedSelector(el);
|
|
663
|
-
if (!cssSelector)
|
|
664
|
-
cssSelector = buildComposedSelector(el);
|
|
665
|
-
if (!cssSelector)
|
|
666
|
-
cssSelector = tryNthChild(el);
|
|
667
|
-
if (!cssSelector)
|
|
668
|
-
cssSelector = buildUniquePath(el);
|
|
669
|
-
if (!cssSelector)
|
|
670
|
-
cssSelector = el.tagName.toLowerCase();
|
|
671
|
-
const xpath = generateXPath(el);
|
|
672
|
-
return { cssSelector, xpath };
|
|
673
|
-
})
|
|
674
|
-
.catch(() => null);
|
|
675
|
-
if (selectorData) {
|
|
676
|
-
result[ref] = {
|
|
677
|
-
cssSelector: selectorData.cssSelector,
|
|
678
|
-
xpath: selectorData.xpath,
|
|
679
|
-
};
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
catch {
|
|
683
|
-
// Intentionally ignored: element ref generation failed for this element
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
return result;
|
|
687
|
-
}
|
|
688
|
-
/**
|
|
689
|
-
* Get enhanced snapshot with refs and optional filtering
|
|
690
|
-
*/
|
|
691
|
-
export async function getEnhancedSnapshot(page, options = {}) {
|
|
692
|
-
if ((options.path || options.attrs) && !options.selector) {
|
|
693
|
-
throw new Error('由于内容可能过大,请使用 selector 参数限定范围');
|
|
694
|
-
}
|
|
695
|
-
resetRefs();
|
|
696
|
-
const refs = {};
|
|
697
|
-
const locator = options.selector ? page.locator(options.selector) : page.locator(':root');
|
|
698
|
-
let ariaTree;
|
|
699
|
-
try {
|
|
700
|
-
ariaTree = await locator.ariaSnapshot({ timeout: 2000 });
|
|
701
|
-
}
|
|
702
|
-
catch (error) {
|
|
703
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
704
|
-
if (errorMessage.includes('Timeout') && options.selector) {
|
|
705
|
-
const suggestedSelectors = await suggestSelectors(page);
|
|
706
|
-
return {
|
|
707
|
-
tree: `(no elements found for selector: ${options.selector})\n\nSuggested selectors: ${suggestedSelectors.join(', ')}`,
|
|
708
|
-
refs: {},
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
throw error;
|
|
712
|
-
}
|
|
713
|
-
if (!ariaTree) {
|
|
714
|
-
return {
|
|
715
|
-
tree: '(empty)',
|
|
716
|
-
refs: {},
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
const enhancedTree = processAriaTree(ariaTree, refs, options);
|
|
720
|
-
if (options.cursor) {
|
|
721
|
-
const cursorElements = await findCursorInteractiveElements(page, options.selector);
|
|
722
|
-
const existingTexts = new Set(Object.values(refs).map((r) => r.name?.toLowerCase()));
|
|
723
|
-
const additionalLines = [];
|
|
724
|
-
for (const el of cursorElements) {
|
|
725
|
-
if (existingTexts.has(el.text.toLowerCase()))
|
|
726
|
-
continue;
|
|
727
|
-
const ref = nextRef();
|
|
728
|
-
const role = el.hasCursorPointer ? 'clickable' : el.hasOnClick ? 'clickable' : 'focusable';
|
|
729
|
-
refs[ref] = {
|
|
730
|
-
selector: el.selector,
|
|
731
|
-
role: role,
|
|
732
|
-
name: el.text,
|
|
733
|
-
};
|
|
734
|
-
const hints = [];
|
|
735
|
-
if (el.hasCursorPointer)
|
|
736
|
-
hints.push('cursor:pointer');
|
|
737
|
-
if (el.hasOnClick)
|
|
738
|
-
hints.push('onclick');
|
|
739
|
-
if (el.hasTabIndex)
|
|
740
|
-
hints.push('tabindex');
|
|
741
|
-
additionalLines.push(`- ${role} "${el.text}" [ref=${ref}] [${hints.join(', ')}]`);
|
|
742
|
-
}
|
|
743
|
-
if (additionalLines.length > 0) {
|
|
744
|
-
const separator = enhancedTree === '(no interactive elements)' ? '' : '\n# Cursor-interactive elements:\n';
|
|
745
|
-
const base = enhancedTree === '(no interactive elements)' ? '' : enhancedTree;
|
|
746
|
-
return {
|
|
747
|
-
tree: base + separator + additionalLines.join('\n'),
|
|
748
|
-
refs,
|
|
749
|
-
};
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
if (options.path || options.attrs) {
|
|
753
|
-
await enrichRefsWithPathsAndAttrs(page, refs, options);
|
|
754
|
-
}
|
|
755
|
-
let finalTree = enhancedTree;
|
|
756
|
-
if (options.selectors && Object.keys(refs).length > 0) {
|
|
757
|
-
const selectorMap = await buildCompactSelectors(page, refs, options);
|
|
758
|
-
if (selectorMap) {
|
|
759
|
-
finalTree += '\n## Selectors\n' + selectorMap;
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
return { tree: finalTree, refs };
|
|
763
|
-
}
|
|
764
|
-
async function buildCompactSelectors(page, refs, options) {
|
|
765
|
-
const entries = Object.entries(refs);
|
|
766
|
-
const parts = [];
|
|
767
|
-
const includeAll = options?.all ?? false;
|
|
768
|
-
for (const [ref, data] of entries) {
|
|
769
|
-
if (data.role === 'clickable' || data.role === 'focusable')
|
|
770
|
-
continue;
|
|
771
|
-
try {
|
|
772
|
-
let locator;
|
|
773
|
-
if (data.name) {
|
|
774
|
-
locator = page.getByRole(data.role, {
|
|
775
|
-
name: data.name,
|
|
776
|
-
exact: true,
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
else {
|
|
780
|
-
locator = page.getByRole(data.role);
|
|
781
|
-
}
|
|
782
|
-
if (data.nth !== undefined)
|
|
783
|
-
locator = locator.nth(data.nth);
|
|
784
|
-
if (!includeAll) {
|
|
785
|
-
const isReallyVisible = await locator
|
|
786
|
-
.evaluate((el) => {
|
|
787
|
-
const style = getComputedStyle(el);
|
|
788
|
-
const rect = el.getBoundingClientRect();
|
|
789
|
-
return !(style.display === 'none' ||
|
|
790
|
-
style.visibility === 'hidden' ||
|
|
791
|
-
parseFloat(style.opacity) === 0 ||
|
|
792
|
-
(rect.width === 0 && rect.height === 0) ||
|
|
793
|
-
rect.x + rect.width < 0 ||
|
|
794
|
-
rect.y + rect.height < 0);
|
|
795
|
-
})
|
|
796
|
-
.catch(() => false);
|
|
797
|
-
if (!isReallyVisible)
|
|
798
|
-
continue;
|
|
799
|
-
}
|
|
800
|
-
const attrs = await locator
|
|
801
|
-
.evaluate((el) => {
|
|
802
|
-
const htmlEl = el;
|
|
803
|
-
const r = {};
|
|
804
|
-
if (htmlEl.dataset.testid)
|
|
805
|
-
r['testid'] = `[data-testid="${htmlEl.dataset.testid}"]`;
|
|
806
|
-
if (htmlEl.id && !htmlEl.id.match(/^[:]/))
|
|
807
|
-
r['id'] = '#' + CSS.escape(htmlEl.id);
|
|
808
|
-
const nameAttr = htmlEl.getAttribute('name');
|
|
809
|
-
if (nameAttr)
|
|
810
|
-
r['name'] = `${htmlEl.tagName.toLowerCase()}[name="${nameAttr}"]`;
|
|
811
|
-
return r;
|
|
812
|
-
})
|
|
813
|
-
.catch(() => null);
|
|
814
|
-
if (!attrs)
|
|
815
|
-
continue;
|
|
816
|
-
let bestSelector = '';
|
|
817
|
-
if (attrs.testid)
|
|
818
|
-
bestSelector = attrs.testid;
|
|
819
|
-
else if (attrs.id)
|
|
820
|
-
bestSelector = attrs.id;
|
|
821
|
-
else if (attrs.name)
|
|
822
|
-
bestSelector = attrs.name;
|
|
823
|
-
if (bestSelector) {
|
|
824
|
-
parts.push(`${ref}: ${bestSelector}`);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
catch {
|
|
828
|
-
// skip
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
return parts.join(' | ');
|
|
832
|
-
}
|
|
833
|
-
async function enrichRefsWithPathsAndAttrs(page, refs, options) {
|
|
834
|
-
if (Object.keys(refs).length === 0) {
|
|
835
|
-
return;
|
|
836
|
-
}
|
|
837
|
-
void `
|
|
838
|
-
() => {
|
|
839
|
-
const STYLE_CLASS_PATTERNS = [
|
|
840
|
-
/^(flex|grid|block|inline|hidden)$/,
|
|
841
|
-
/^(mt|mb|ml|mr|mx|my|pt|pb|pl|pr|px|py)-?\\d*$/,
|
|
842
|
-
/^(w|h|min|max)-/,
|
|
843
|
-
/^(text|font|bg|border)-/,
|
|
844
|
-
/^(rounded|shadow|opacity)-/,
|
|
845
|
-
/^(hover|focus|active):/,
|
|
846
|
-
/^(items|justify|gap|space)-/,
|
|
847
|
-
/^(p|m)-\\d*$/,
|
|
848
|
-
/^transition/,
|
|
849
|
-
/^duration/,
|
|
850
|
-
/^ease/,
|
|
851
|
-
/^transform/,
|
|
852
|
-
/^scale|rotate|translate/,
|
|
853
|
-
];
|
|
854
|
-
|
|
855
|
-
const SEMANTIC_TAGS = new Set([
|
|
856
|
-
'main', 'nav', 'header', 'footer', 'article', 'section', 'aside', 'form'
|
|
857
|
-
]);
|
|
858
|
-
|
|
859
|
-
function getSemanticClass(element) {
|
|
860
|
-
const className = element.getAttribute('class');
|
|
861
|
-
if (!className) return null;
|
|
862
|
-
const classes = className.split(/\\s+/).filter(cls => {
|
|
863
|
-
return !STYLE_CLASS_PATTERNS.some(p => p.test(cls));
|
|
864
|
-
});
|
|
865
|
-
if (classes.length === 0) return null;
|
|
866
|
-
const selectedClasses = classes.slice(0, 2);
|
|
867
|
-
return selectedClasses.map(cls => 'contains(@class, "' + cls + '")').join(' and ');
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
function getElementIndex(element) {
|
|
871
|
-
const parent = element.parentElement;
|
|
872
|
-
if (!parent) return 1;
|
|
873
|
-
const siblings = Array.from(parent.children).filter(
|
|
874
|
-
child => child.tagName === element.tagName
|
|
875
|
-
);
|
|
876
|
-
return siblings.indexOf(element) + 1;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
function buildRelativeXPath(element, maxDepth) {
|
|
880
|
-
const path = [];
|
|
881
|
-
let current = element;
|
|
882
|
-
let depth = 0;
|
|
883
|
-
while (current && depth < maxDepth) {
|
|
884
|
-
if (current.id) {
|
|
885
|
-
path.unshift('//*[@id="' + current.id + '"]');
|
|
886
|
-
break;
|
|
887
|
-
}
|
|
888
|
-
const testId = current.getAttribute('data-testid');
|
|
889
|
-
if (testId) {
|
|
890
|
-
path.unshift('//*[@data-testid="' + testId + '"]');
|
|
891
|
-
break;
|
|
892
|
-
}
|
|
893
|
-
const tagName = current.tagName.toLowerCase();
|
|
894
|
-
if (SEMANTIC_TAGS.has(tagName)) {
|
|
895
|
-
const index = getElementIndex(current);
|
|
896
|
-
path.unshift('//' + tagName + '[' + index + ']');
|
|
897
|
-
break;
|
|
898
|
-
}
|
|
899
|
-
const index = getElementIndex(current);
|
|
900
|
-
path.unshift(tagName + '[' + index + ']');
|
|
901
|
-
current = current.parentElement;
|
|
902
|
-
depth++;
|
|
903
|
-
}
|
|
904
|
-
if (path.length > 0 && !path[0].startsWith('//')) {
|
|
905
|
-
path.unshift('//');
|
|
906
|
-
}
|
|
907
|
-
return path.join('/');
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
function generateXPath(element, maxDepth) {
|
|
911
|
-
if (element.id) return '//*[@id="' + element.id + '"]';
|
|
912
|
-
const testId = element.getAttribute('data-testid');
|
|
913
|
-
if (testId) return '//*[@data-testid="' + testId + '"]';
|
|
914
|
-
const dataId = element.getAttribute('data-id');
|
|
915
|
-
if (dataId) return '//*[@data-id="' + dataId + '"]';
|
|
916
|
-
const semanticClass = getSemanticClass(element);
|
|
917
|
-
if (semanticClass) return '//' + element.tagName.toLowerCase() + '[' + semanticClass + ']';
|
|
918
|
-
return buildRelativeXPath(element, maxDepth);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
function buildElementSelector(element) {
|
|
922
|
-
const tagName = element.tagName.toLowerCase();
|
|
923
|
-
const className = element.getAttribute('class');
|
|
924
|
-
if (className) {
|
|
925
|
-
const classes = className.split(/\\s+/).filter(cls => {
|
|
926
|
-
return !STYLE_CLASS_PATTERNS.some(p => p.test(cls));
|
|
927
|
-
});
|
|
928
|
-
if (classes.length > 0) {
|
|
929
|
-
return tagName + '.' + classes.slice(0, 2).join('.');
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
const parent = element.parentElement;
|
|
933
|
-
if (parent) {
|
|
934
|
-
const index = Array.from(parent.children).indexOf(element) + 1;
|
|
935
|
-
return tagName + ':nth-child(' + index + ')';
|
|
936
|
-
}
|
|
937
|
-
return tagName;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
function generateCSSPath(element, maxDepth) {
|
|
941
|
-
if (element.id) return '#' + element.id;
|
|
942
|
-
const testId = element.getAttribute('data-testid');
|
|
943
|
-
if (testId) return '[data-testid="' + testId + '"]';
|
|
944
|
-
const path = [];
|
|
945
|
-
let current = element;
|
|
946
|
-
let depth = 0;
|
|
947
|
-
while (current && depth < maxDepth) {
|
|
948
|
-
if (current.id) {
|
|
949
|
-
path.unshift('#' + current.id);
|
|
950
|
-
break;
|
|
951
|
-
}
|
|
952
|
-
const testId = current.getAttribute('data-testid');
|
|
953
|
-
if (testId) {
|
|
954
|
-
path.unshift('[data-testid="' + testId + '"]');
|
|
955
|
-
break;
|
|
956
|
-
}
|
|
957
|
-
const tagName = current.tagName.toLowerCase();
|
|
958
|
-
if (SEMANTIC_TAGS.has(tagName)) {
|
|
959
|
-
path.unshift(tagName);
|
|
960
|
-
break;
|
|
961
|
-
}
|
|
962
|
-
const selector = buildElementSelector(current);
|
|
963
|
-
path.unshift(selector);
|
|
964
|
-
current = current.parentElement;
|
|
965
|
-
depth++;
|
|
966
|
-
}
|
|
967
|
-
return path.join(' > ');
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
function collectAttributes(element) {
|
|
971
|
-
const attrs = {};
|
|
972
|
-
for (let i = 0; i < element.attributes.length; i++) {
|
|
973
|
-
const attr = element.attributes[i];
|
|
974
|
-
attrs[attr.name] = attr.value;
|
|
975
|
-
}
|
|
976
|
-
return attrs;
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// Map role to implicit tag names
|
|
980
|
-
const roleToTag = {
|
|
981
|
-
'heading': ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
|
982
|
-
'link': ['a'],
|
|
983
|
-
'button': ['button', 'input[type="button"]', 'input[type="submit"]'],
|
|
984
|
-
'textbox': ['input[type="text"]', 'input:not([type])', 'textarea'],
|
|
985
|
-
'checkbox': ['input[type="checkbox"]'],
|
|
986
|
-
'radio': ['input[type="radio"]'],
|
|
987
|
-
'listitem': ['li'],
|
|
988
|
-
'list': ['ul', 'ol'],
|
|
989
|
-
'navigation': ['nav'],
|
|
990
|
-
'main': ['main'],
|
|
991
|
-
'article': ['article'],
|
|
992
|
-
'section': ['section'],
|
|
993
|
-
'form': ['form'],
|
|
994
|
-
};
|
|
995
|
-
|
|
996
|
-
function getImplicitRole(element) {
|
|
997
|
-
const tag = element.tagName.toLowerCase();
|
|
998
|
-
const type = element.getAttribute('type');
|
|
999
|
-
|
|
1000
|
-
// Check explicit role first
|
|
1001
|
-
const explicitRole = element.getAttribute('role');
|
|
1002
|
-
if (explicitRole) return explicitRole.toLowerCase();
|
|
1003
|
-
|
|
1004
|
-
// Check implicit roles
|
|
1005
|
-
if (tag === 'a' && element.hasAttribute('href')) return 'link';
|
|
1006
|
-
if (tag === 'button') return 'button';
|
|
1007
|
-
if (tag === 'input') {
|
|
1008
|
-
if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
|
|
1009
|
-
if (type === 'checkbox') return 'checkbox';
|
|
1010
|
-
if (type === 'radio') return 'radio';
|
|
1011
|
-
if (type === 'file') return 'button'; // file input is rendered as button
|
|
1012
|
-
if (type === 'range') return 'slider';
|
|
1013
|
-
if (type === 'number') return 'spinbutton';
|
|
1014
|
-
if (type === 'date' || type === 'datetime-local' || type === 'month' || type === 'week' || type === 'time') return 'textbox';
|
|
1015
|
-
return 'textbox';
|
|
1016
|
-
}
|
|
1017
|
-
if (tag === 'textarea') return 'textbox';
|
|
1018
|
-
if (tag === 'select') return 'combobox';
|
|
1019
|
-
if (tag === 'img' && element.hasAttribute('alt')) return 'img';
|
|
1020
|
-
if (/^h[1-6]$/.test(tag)) return 'heading';
|
|
1021
|
-
if (tag === 'nav') return 'navigation';
|
|
1022
|
-
if (tag === 'main') return 'main';
|
|
1023
|
-
if (tag === 'article') return 'article';
|
|
1024
|
-
if (tag === 'section') return 'section';
|
|
1025
|
-
if (tag === 'form') return 'form';
|
|
1026
|
-
if (tag === 'ul' || tag === 'ol') return 'list';
|
|
1027
|
-
if (tag === 'li') return 'listitem';
|
|
1028
|
-
|
|
1029
|
-
return null;
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
const results = {};
|
|
1033
|
-
const refEntries = Object.entries(window.__AGENT_BROWSER_REFS__ || {});
|
|
1034
|
-
|
|
1035
|
-
for (const [ref, data] of refEntries) {
|
|
1036
|
-
const targetRole = data.role;
|
|
1037
|
-
const targetName = data.name;
|
|
1038
|
-
const nth = data.nth;
|
|
1039
|
-
|
|
1040
|
-
// Find matching elements
|
|
1041
|
-
let elements = [];
|
|
1042
|
-
const allElements = document.querySelectorAll('*');
|
|
1043
|
-
|
|
1044
|
-
for (const el of allElements) {
|
|
1045
|
-
const elRole = getImplicitRole(el);
|
|
1046
|
-
if (elRole !== targetRole && targetRole !== 'clickable' && targetRole !== 'focusable') continue;
|
|
1047
|
-
|
|
1048
|
-
// Get accessible name
|
|
1049
|
-
let elName = el.getAttribute('aria-label') ||
|
|
1050
|
-
el.getAttribute('title') ||
|
|
1051
|
-
el.getAttribute('alt') || '';
|
|
1052
|
-
|
|
1053
|
-
if (!elName) {
|
|
1054
|
-
// For heading, use text content
|
|
1055
|
-
if (targetRole === 'heading') {
|
|
1056
|
-
elName = el.textContent?.trim() || '';
|
|
1057
|
-
}
|
|
1058
|
-
// For link, use text content
|
|
1059
|
-
else if (targetRole === 'link' || el.tagName.toLowerCase() === 'a') {
|
|
1060
|
-
elName = el.textContent?.trim() || '';
|
|
1061
|
-
// Also check img alt inside link
|
|
1062
|
-
if (!elName) {
|
|
1063
|
-
const img = el.querySelector('img[alt]');
|
|
1064
|
-
if (img) elName = img.getAttribute('alt') || '';
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
// For button, use text or value
|
|
1068
|
-
else if (targetRole === 'button') {
|
|
1069
|
-
elName = el.textContent?.trim() || el.getAttribute('value') || '';
|
|
1070
|
-
// Special case for file input - Playwright shows "Choose File" but element has no name
|
|
1071
|
-
if (!elName && el.tagName.toLowerCase() === 'input' && el.getAttribute('type') === 'file') {
|
|
1072
|
-
elName = 'choose file'; // Match Playwright's localized name
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
// For textbox, use label or placeholder
|
|
1076
|
-
else if (targetRole === 'textbox') {
|
|
1077
|
-
elName = el.getAttribute('placeholder') || '';
|
|
1078
|
-
const label = el.labels?.[0]?.textContent?.trim();
|
|
1079
|
-
if (label) elName = label;
|
|
1080
|
-
}
|
|
1081
|
-
else {
|
|
1082
|
-
elName = el.textContent?.trim().slice(0, 100) || '';
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
// Match name
|
|
1087
|
-
if (targetName) {
|
|
1088
|
-
const normalizedElName = elName.toLowerCase().trim();
|
|
1089
|
-
const normalizedTargetName = targetName.toLowerCase().trim();
|
|
1090
|
-
if (normalizedElName !== normalizedTargetName && !normalizedElName.includes(normalizedTargetName)) {
|
|
1091
|
-
continue;
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
elements.push(el);
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
if (elements.length > 0) {
|
|
1099
|
-
const element = nth !== undefined ? elements[nth] : elements[0];
|
|
1100
|
-
if (element) {
|
|
1101
|
-
results[ref] = {
|
|
1102
|
-
xpath: generateXPath(element, 5),
|
|
1103
|
-
cssPath: generateCSSPath(element, 5),
|
|
1104
|
-
attributes: collectAttributes(element),
|
|
1105
|
-
};
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
return results;
|
|
1111
|
-
}
|
|
1112
|
-
`;
|
|
1113
|
-
const injectScript = `
|
|
1114
|
-
window.__AGENT_BROWSER_REFS__ = ${JSON.stringify(refs)};
|
|
1115
|
-
`;
|
|
1116
|
-
if ('evaluate' in page) {
|
|
1117
|
-
await page.evaluate(injectScript);
|
|
1118
|
-
}
|
|
1119
|
-
// Evaluate the function in the browser context
|
|
1120
|
-
const elementData = await page.evaluate(() => {
|
|
1121
|
-
const STYLE_CLASS_PATTERNS = [
|
|
1122
|
-
/^(flex|grid|block|inline|hidden)$/,
|
|
1123
|
-
/^(mt|mb|ml|mr|mx|my|pt|pb|pl|pr|px|py)-?\d*$/,
|
|
1124
|
-
/^(w|h|min|max)-/,
|
|
1125
|
-
/^(text|font|bg|border)-/,
|
|
1126
|
-
/^(rounded|shadow|opacity)-/,
|
|
1127
|
-
/^(hover|focus|active):/,
|
|
1128
|
-
/^(items|justify|gap|space)-/,
|
|
1129
|
-
/^(p|m)-\d*$/,
|
|
1130
|
-
/^transition/,
|
|
1131
|
-
/^duration/,
|
|
1132
|
-
/^ease/,
|
|
1133
|
-
/^transform/,
|
|
1134
|
-
/^scale|rotate|translate/,
|
|
1135
|
-
];
|
|
1136
|
-
const SEMANTIC_TAGS = new Set([
|
|
1137
|
-
'main',
|
|
1138
|
-
'nav',
|
|
1139
|
-
'header',
|
|
1140
|
-
'footer',
|
|
1141
|
-
'article',
|
|
1142
|
-
'section',
|
|
1143
|
-
'aside',
|
|
1144
|
-
'form',
|
|
1145
|
-
]);
|
|
1146
|
-
function getSemanticClass(element) {
|
|
1147
|
-
const className = element.getAttribute('class');
|
|
1148
|
-
if (!className)
|
|
1149
|
-
return null;
|
|
1150
|
-
const classes = className.split(/\s+/).filter((cls) => {
|
|
1151
|
-
return !STYLE_CLASS_PATTERNS.some((p) => p.test(cls));
|
|
1152
|
-
});
|
|
1153
|
-
if (classes.length === 0)
|
|
1154
|
-
return null;
|
|
1155
|
-
const selectedClasses = classes.slice(0, 2);
|
|
1156
|
-
return selectedClasses.map((cls) => 'contains(@class, "' + cls + '")').join(' and ');
|
|
1157
|
-
}
|
|
1158
|
-
function getElementIndex(element) {
|
|
1159
|
-
const parent = element.parentElement;
|
|
1160
|
-
if (!parent)
|
|
1161
|
-
return 1;
|
|
1162
|
-
const siblings = Array.from(parent.children).filter((child) => child.tagName === element.tagName);
|
|
1163
|
-
return siblings.indexOf(element) + 1;
|
|
1164
|
-
}
|
|
1165
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1166
|
-
function buildRelativeXPath(element, maxDepth) {
|
|
1167
|
-
const path = [];
|
|
1168
|
-
let current = element;
|
|
1169
|
-
let depth = 0;
|
|
1170
|
-
while (current && depth < maxDepth) {
|
|
1171
|
-
if (current.id) {
|
|
1172
|
-
path.unshift('//*[@id="' + current.id + '"]');
|
|
1173
|
-
break;
|
|
1174
|
-
}
|
|
1175
|
-
const testId = current.getAttribute('data-testid');
|
|
1176
|
-
if (testId) {
|
|
1177
|
-
path.unshift('//*[@data-testid="' + testId + '"]');
|
|
1178
|
-
break;
|
|
1179
|
-
}
|
|
1180
|
-
const tagName = current.tagName.toLowerCase();
|
|
1181
|
-
if (SEMANTIC_TAGS.has(tagName)) {
|
|
1182
|
-
const index = getElementIndex(current);
|
|
1183
|
-
path.unshift('//' + tagName + '[' + index + ']');
|
|
1184
|
-
break;
|
|
1185
|
-
}
|
|
1186
|
-
const index = getElementIndex(current);
|
|
1187
|
-
path.unshift(tagName + '[' + index + ']');
|
|
1188
|
-
current = current.parentElement;
|
|
1189
|
-
depth++;
|
|
1190
|
-
}
|
|
1191
|
-
if (path.length > 0 && !path[0].startsWith('//')) {
|
|
1192
|
-
path.unshift('//');
|
|
1193
|
-
}
|
|
1194
|
-
return path.join('/');
|
|
1195
|
-
}
|
|
1196
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1197
|
-
function generateXPath(element, maxDepth) {
|
|
1198
|
-
if (element.id)
|
|
1199
|
-
return '//*[@id="' + element.id + '"]';
|
|
1200
|
-
const testId = element.getAttribute('data-testid');
|
|
1201
|
-
if (testId)
|
|
1202
|
-
return '//*[@data-testid="' + testId + '"]';
|
|
1203
|
-
const dataId = element.getAttribute('data-id');
|
|
1204
|
-
if (dataId)
|
|
1205
|
-
return '//*[@data-id="' + dataId + '"]';
|
|
1206
|
-
const semanticClass = getSemanticClass(element);
|
|
1207
|
-
if (semanticClass)
|
|
1208
|
-
return '//' + element.tagName.toLowerCase() + '[' + semanticClass + ']';
|
|
1209
|
-
return buildRelativeXPath(element, maxDepth);
|
|
1210
|
-
}
|
|
1211
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1212
|
-
function buildElementSelector(element) {
|
|
1213
|
-
const tagName = element.tagName.toLowerCase();
|
|
1214
|
-
const className = element.getAttribute('class');
|
|
1215
|
-
if (className) {
|
|
1216
|
-
const classes = className.split(/\s+/).filter((cls) => {
|
|
1217
|
-
return !STYLE_CLASS_PATTERNS.some((p) => p.test(cls));
|
|
1218
|
-
});
|
|
1219
|
-
if (classes.length > 0) {
|
|
1220
|
-
return tagName + '.' + classes.slice(0, 2).join('.');
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
const parent = element.parentElement;
|
|
1224
|
-
if (parent) {
|
|
1225
|
-
const index = Array.from(parent.children).indexOf(element) + 1;
|
|
1226
|
-
return tagName + ':nth-child(' + index + ')';
|
|
1227
|
-
}
|
|
1228
|
-
return tagName;
|
|
1229
|
-
}
|
|
1230
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1231
|
-
function generateCSSPath(element, maxDepth) {
|
|
1232
|
-
if (element.id)
|
|
1233
|
-
return '#' + element.id;
|
|
1234
|
-
const testId = element.getAttribute('data-testid');
|
|
1235
|
-
if (testId)
|
|
1236
|
-
return '[data-testid="' + testId + '"]';
|
|
1237
|
-
const path = [];
|
|
1238
|
-
let current = element;
|
|
1239
|
-
let depth = 0;
|
|
1240
|
-
while (current && depth < maxDepth) {
|
|
1241
|
-
if (current.id) {
|
|
1242
|
-
path.unshift('#' + current.id);
|
|
1243
|
-
break;
|
|
1244
|
-
}
|
|
1245
|
-
const testId = current.getAttribute('data-testid');
|
|
1246
|
-
if (testId) {
|
|
1247
|
-
path.unshift('[data-testid="' + testId + '"]');
|
|
1248
|
-
break;
|
|
1249
|
-
}
|
|
1250
|
-
const tagName = current.tagName.toLowerCase();
|
|
1251
|
-
if (SEMANTIC_TAGS.has(tagName)) {
|
|
1252
|
-
path.unshift(tagName);
|
|
1253
|
-
break;
|
|
1254
|
-
}
|
|
1255
|
-
const selector = buildElementSelector(current);
|
|
1256
|
-
path.unshift(selector);
|
|
1257
|
-
current = current.parentElement;
|
|
1258
|
-
depth++;
|
|
1259
|
-
}
|
|
1260
|
-
return path.join(' > ');
|
|
1261
|
-
}
|
|
1262
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1263
|
-
function collectAttributes(element) {
|
|
1264
|
-
const attrs = {};
|
|
1265
|
-
for (let i = 0; i < element.attributes.length; i++) {
|
|
1266
|
-
const attr = element.attributes[i];
|
|
1267
|
-
attrs[attr.name] = attr.value;
|
|
1268
|
-
}
|
|
1269
|
-
return attrs;
|
|
1270
|
-
}
|
|
1271
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1272
|
-
function getImplicitRole(element) {
|
|
1273
|
-
const tag = element.tagName.toLowerCase();
|
|
1274
|
-
const type = element.getAttribute('type');
|
|
1275
|
-
const explicitRole = element.getAttribute('role');
|
|
1276
|
-
if (explicitRole)
|
|
1277
|
-
return explicitRole.toLowerCase();
|
|
1278
|
-
if (tag === 'a' && element.hasAttribute('href'))
|
|
1279
|
-
return 'link';
|
|
1280
|
-
if (tag === 'button')
|
|
1281
|
-
return 'button';
|
|
1282
|
-
if (tag === 'input') {
|
|
1283
|
-
if (type === 'button' || type === 'submit' || type === 'reset')
|
|
1284
|
-
return 'button';
|
|
1285
|
-
if (type === 'checkbox')
|
|
1286
|
-
return 'checkbox';
|
|
1287
|
-
if (type === 'radio')
|
|
1288
|
-
return 'radio';
|
|
1289
|
-
if (type === 'file')
|
|
1290
|
-
return 'button'; // file input is rendered as button
|
|
1291
|
-
if (type === 'range')
|
|
1292
|
-
return 'slider';
|
|
1293
|
-
if (type === 'number')
|
|
1294
|
-
return 'spinbutton';
|
|
1295
|
-
return 'textbox';
|
|
1296
|
-
}
|
|
1297
|
-
if (tag === 'textarea')
|
|
1298
|
-
return 'textbox';
|
|
1299
|
-
if (tag === 'select')
|
|
1300
|
-
return 'combobox';
|
|
1301
|
-
if (tag === 'img' && element.hasAttribute('alt'))
|
|
1302
|
-
return 'img';
|
|
1303
|
-
if (/^h[1-6]$/.test(tag))
|
|
1304
|
-
return 'heading';
|
|
1305
|
-
if (tag === 'nav')
|
|
1306
|
-
return 'navigation';
|
|
1307
|
-
if (tag === 'main')
|
|
1308
|
-
return 'main';
|
|
1309
|
-
if (tag === 'article')
|
|
1310
|
-
return 'article';
|
|
1311
|
-
if (tag === 'section')
|
|
1312
|
-
return 'section';
|
|
1313
|
-
if (tag === 'form')
|
|
1314
|
-
return 'form';
|
|
1315
|
-
if (tag === 'ul' || tag === 'ol')
|
|
1316
|
-
return 'list';
|
|
1317
|
-
if (tag === 'li')
|
|
1318
|
-
return 'listitem';
|
|
1319
|
-
return null;
|
|
1320
|
-
}
|
|
1321
|
-
const results = {};
|
|
1322
|
-
const refEntries = Object.entries(window.__AGENT_BROWSER_REFS__ || {});
|
|
1323
|
-
for (const [ref, data] of refEntries) {
|
|
1324
|
-
const targetRole = data.role;
|
|
1325
|
-
const targetName = data.name;
|
|
1326
|
-
const nth = data.nth;
|
|
1327
|
-
const elements = [];
|
|
1328
|
-
const allElements = Array.from(document.querySelectorAll('*'));
|
|
1329
|
-
for (const el of allElements) {
|
|
1330
|
-
const elRole = getImplicitRole(el);
|
|
1331
|
-
if (elRole !== targetRole && targetRole !== 'clickable' && targetRole !== 'focusable')
|
|
1332
|
-
continue;
|
|
1333
|
-
let elName = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('alt') || '';
|
|
1334
|
-
if (!elName) {
|
|
1335
|
-
if (targetRole === 'heading') {
|
|
1336
|
-
elName = el.textContent?.trim() || '';
|
|
1337
|
-
}
|
|
1338
|
-
else if (targetRole === 'link' || el.tagName.toLowerCase() === 'a') {
|
|
1339
|
-
elName = el.textContent?.trim() || '';
|
|
1340
|
-
if (!elName) {
|
|
1341
|
-
const img = el.querySelector('img[alt]');
|
|
1342
|
-
if (img)
|
|
1343
|
-
elName = img.getAttribute('alt') || '';
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
else if (targetRole === 'button') {
|
|
1347
|
-
elName = el.textContent?.trim() || el.getAttribute('value') || '';
|
|
1348
|
-
// Special case for file input - Playwright shows "Choose File" but element has no name
|
|
1349
|
-
if (!elName &&
|
|
1350
|
-
el.tagName.toLowerCase() === 'input' &&
|
|
1351
|
-
el.getAttribute('type') === 'file') {
|
|
1352
|
-
elName = 'choose file'; // Match Playwright's localized name
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
else if (targetRole === 'textbox') {
|
|
1356
|
-
elName = el.getAttribute('placeholder') || '';
|
|
1357
|
-
const label = el.labels?.[0]?.textContent?.trim();
|
|
1358
|
-
if (label)
|
|
1359
|
-
elName = label;
|
|
1360
|
-
}
|
|
1361
|
-
else {
|
|
1362
|
-
elName = el.textContent?.trim().slice(0, 100) || '';
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
if (targetName) {
|
|
1366
|
-
const normalizedElName = elName.toLowerCase().trim();
|
|
1367
|
-
const normalizedTargetName = targetName.toLowerCase().trim();
|
|
1368
|
-
if (normalizedElName !== normalizedTargetName &&
|
|
1369
|
-
!normalizedElName.includes(normalizedTargetName)) {
|
|
1370
|
-
continue;
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
elements.push(el);
|
|
1374
|
-
}
|
|
1375
|
-
if (elements.length > 0) {
|
|
1376
|
-
const element = nth !== undefined ? elements[nth] : elements[0];
|
|
1377
|
-
if (element) {
|
|
1378
|
-
results[ref] = {
|
|
1379
|
-
xpath: generateXPath(element, 5),
|
|
1380
|
-
cssPath: generateCSSPath(element, 5),
|
|
1381
|
-
attributes: collectAttributes(element),
|
|
1382
|
-
};
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
return results;
|
|
1387
|
-
});
|
|
1388
|
-
if (!elementData || typeof elementData !== 'object') {
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
for (const [ref, data] of Object.entries(elementData)) {
|
|
1392
|
-
const typedData = data;
|
|
1393
|
-
if (refs[ref] && data) {
|
|
1394
|
-
if (options.path) {
|
|
1395
|
-
refs[ref].xpath = typedData.xpath;
|
|
1396
|
-
refs[ref].cssPath = typedData.cssPath;
|
|
1397
|
-
}
|
|
1398
|
-
if (options.attrs) {
|
|
1399
|
-
refs[ref].attributes = typedData.attributes;
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
function createRoleNameTracker() {
|
|
1405
|
-
const counts = new Map();
|
|
1406
|
-
const refsByKey = new Map();
|
|
1407
|
-
return {
|
|
1408
|
-
counts,
|
|
1409
|
-
refsByKey,
|
|
1410
|
-
getKey(role, name) {
|
|
1411
|
-
return `${role}:${name ?? ''}`;
|
|
1412
|
-
},
|
|
1413
|
-
getNextIndex(role, name) {
|
|
1414
|
-
const key = this.getKey(role, name);
|
|
1415
|
-
const current = counts.get(key) ?? 0;
|
|
1416
|
-
counts.set(key, current + 1);
|
|
1417
|
-
return current;
|
|
1418
|
-
},
|
|
1419
|
-
trackRef(role, name, ref) {
|
|
1420
|
-
const key = this.getKey(role, name);
|
|
1421
|
-
const refs = refsByKey.get(key) ?? [];
|
|
1422
|
-
refs.push(ref);
|
|
1423
|
-
refsByKey.set(key, refs);
|
|
1424
|
-
},
|
|
1425
|
-
getDuplicateKeys() {
|
|
1426
|
-
const duplicates = new Set();
|
|
1427
|
-
for (const [key, refs] of refsByKey) {
|
|
1428
|
-
if (refs.length > 1) {
|
|
1429
|
-
duplicates.add(key);
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
return duplicates;
|
|
1433
|
-
},
|
|
1434
|
-
};
|
|
1435
|
-
}
|
|
1436
|
-
/**
|
|
1437
|
-
* Process ARIA snapshot: add refs and apply filters
|
|
1438
|
-
*/
|
|
1439
|
-
function processAriaTree(ariaTree, refs, options) {
|
|
1440
|
-
const lines = ariaTree.split('\n');
|
|
1441
|
-
const result = [];
|
|
1442
|
-
const tracker = createRoleNameTracker();
|
|
1443
|
-
// For interactive-only mode, we collect just interactive elements
|
|
1444
|
-
if (options.interactive) {
|
|
1445
|
-
for (const line of lines) {
|
|
1446
|
-
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
|
|
1447
|
-
if (!match)
|
|
1448
|
-
continue;
|
|
1449
|
-
const [, , role, name, suffix] = match;
|
|
1450
|
-
const roleLower = role.toLowerCase();
|
|
1451
|
-
if (INTERACTIVE_ROLES.has(roleLower)) {
|
|
1452
|
-
const ref = nextRef();
|
|
1453
|
-
const nth = tracker.getNextIndex(roleLower, name);
|
|
1454
|
-
tracker.trackRef(roleLower, name, ref);
|
|
1455
|
-
refs[ref] = {
|
|
1456
|
-
selector: buildSelector(roleLower, name),
|
|
1457
|
-
role: roleLower,
|
|
1458
|
-
name,
|
|
1459
|
-
nth, // Always store nth, we'll use it for duplicates
|
|
1460
|
-
};
|
|
1461
|
-
let enhanced = `- ${role}`;
|
|
1462
|
-
if (name)
|
|
1463
|
-
enhanced += ` "${name}"`;
|
|
1464
|
-
enhanced += ` [ref=${ref}]`;
|
|
1465
|
-
// Only show nth in output if it's > 0 (for readability)
|
|
1466
|
-
if (nth > 0)
|
|
1467
|
-
enhanced += ` [nth=${nth}]`;
|
|
1468
|
-
if (suffix && suffix.includes('['))
|
|
1469
|
-
enhanced += suffix;
|
|
1470
|
-
result.push(enhanced);
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
// Post-process: remove nth from refs that don't have duplicates
|
|
1474
|
-
removeNthFromNonDuplicates(refs, tracker);
|
|
1475
|
-
return result.join('\n') || '(no interactive elements)';
|
|
1476
|
-
}
|
|
1477
|
-
// Normal processing with depth/compact filters
|
|
1478
|
-
for (const line of lines) {
|
|
1479
|
-
const processed = processLine(line, refs, options, tracker);
|
|
1480
|
-
if (processed !== null) {
|
|
1481
|
-
result.push(processed);
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
// Post-process: remove nth from refs that don't have duplicates
|
|
1485
|
-
removeNthFromNonDuplicates(refs, tracker);
|
|
1486
|
-
// If compact mode, remove empty structural elements
|
|
1487
|
-
if (options.compact) {
|
|
1488
|
-
return compactTree(result.join('\n'));
|
|
1489
|
-
}
|
|
1490
|
-
return result.join('\n');
|
|
1491
|
-
}
|
|
1492
|
-
/**
|
|
1493
|
-
* Remove nth from refs that ended up not having duplicates
|
|
1494
|
-
* This keeps single-element locators simple (no unnecessary .nth(0))
|
|
1495
|
-
*/
|
|
1496
|
-
function removeNthFromNonDuplicates(refs, tracker) {
|
|
1497
|
-
const duplicateKeys = tracker.getDuplicateKeys();
|
|
1498
|
-
for (const [ref, data] of Object.entries(refs)) {
|
|
1499
|
-
const key = tracker.getKey(data.role, data.name);
|
|
1500
|
-
if (!duplicateKeys.has(key)) {
|
|
1501
|
-
// Not a duplicate, remove nth to keep locator simple
|
|
1502
|
-
delete refs[ref].nth;
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
/**
|
|
1507
|
-
* Get indentation level (number of spaces / 2)
|
|
1508
|
-
*/
|
|
1509
|
-
function getIndentLevel(line) {
|
|
1510
|
-
const match = line.match(/^(\s*)/);
|
|
1511
|
-
return match ? Math.floor(match[1].length / 2) : 0;
|
|
1512
|
-
}
|
|
1513
|
-
/**
|
|
1514
|
-
* Process a single line: add ref if needed, filter if requested
|
|
1515
|
-
*/
|
|
1516
|
-
function processLine(line, refs, options, tracker) {
|
|
1517
|
-
const depth = getIndentLevel(line);
|
|
1518
|
-
// Check max depth
|
|
1519
|
-
if (options.maxDepth !== undefined && depth > options.maxDepth) {
|
|
1520
|
-
return null;
|
|
1521
|
-
}
|
|
1522
|
-
// Match lines like:
|
|
1523
|
-
// - button "Submit"
|
|
1524
|
-
// - heading "Title" [level=1]
|
|
1525
|
-
// - link "Click me":
|
|
1526
|
-
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
|
|
1527
|
-
if (!match) {
|
|
1528
|
-
// Metadata lines (like /url:) or text content
|
|
1529
|
-
if (options.interactive) {
|
|
1530
|
-
// In interactive mode, only keep metadata under interactive elements
|
|
1531
|
-
return null;
|
|
1532
|
-
}
|
|
1533
|
-
return line;
|
|
1534
|
-
}
|
|
1535
|
-
const [, prefix, role, name, suffix] = match;
|
|
1536
|
-
const roleLower = role.toLowerCase();
|
|
1537
|
-
// Skip metadata lines (like /url:)
|
|
1538
|
-
if (role.startsWith('/')) {
|
|
1539
|
-
return line;
|
|
1540
|
-
}
|
|
1541
|
-
const isInteractive = INTERACTIVE_ROLES.has(roleLower);
|
|
1542
|
-
const isContent = CONTENT_ROLES.has(roleLower);
|
|
1543
|
-
const isStructural = STRUCTURAL_ROLES.has(roleLower);
|
|
1544
|
-
// In interactive-only mode, filter non-interactive elements
|
|
1545
|
-
if (options.interactive && !isInteractive) {
|
|
1546
|
-
return null;
|
|
1547
|
-
}
|
|
1548
|
-
// In compact mode, skip unnamed structural elements
|
|
1549
|
-
if (options.compact && isStructural && !name) {
|
|
1550
|
-
return null;
|
|
1551
|
-
}
|
|
1552
|
-
// Add ref for interactive or named content elements
|
|
1553
|
-
const shouldHaveRef = isInteractive || (isContent && name);
|
|
1554
|
-
if (shouldHaveRef) {
|
|
1555
|
-
const ref = nextRef();
|
|
1556
|
-
const nth = tracker.getNextIndex(roleLower, name);
|
|
1557
|
-
tracker.trackRef(roleLower, name, ref);
|
|
1558
|
-
refs[ref] = {
|
|
1559
|
-
selector: buildSelector(roleLower, name),
|
|
1560
|
-
role: roleLower,
|
|
1561
|
-
name,
|
|
1562
|
-
nth, // Always store nth, we'll clean up non-duplicates later
|
|
1563
|
-
};
|
|
1564
|
-
// Build enhanced line with ref
|
|
1565
|
-
let enhanced = `${prefix}${role}`;
|
|
1566
|
-
if (name)
|
|
1567
|
-
enhanced += ` "${name}"`;
|
|
1568
|
-
enhanced += ` [ref=${ref}]`;
|
|
1569
|
-
// Only show nth in output if it's > 0 (for readability)
|
|
1570
|
-
if (nth > 0)
|
|
1571
|
-
enhanced += ` [nth=${nth}]`;
|
|
1572
|
-
if (suffix)
|
|
1573
|
-
enhanced += suffix;
|
|
1574
|
-
return enhanced;
|
|
1575
|
-
}
|
|
1576
|
-
return line;
|
|
1577
|
-
}
|
|
1578
|
-
/**
|
|
1579
|
-
* Remove empty structural branches in compact mode
|
|
1580
|
-
*/
|
|
1581
|
-
function compactTree(tree) {
|
|
1582
|
-
const lines = tree.split('\n');
|
|
1583
|
-
const result = [];
|
|
1584
|
-
// Simple pass: keep lines that have content or refs
|
|
1585
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1586
|
-
const line = lines[i];
|
|
1587
|
-
// Always keep lines with refs
|
|
1588
|
-
if (line.includes('[ref=')) {
|
|
1589
|
-
result.push(line);
|
|
1590
|
-
continue;
|
|
1591
|
-
}
|
|
1592
|
-
// Keep lines with text content (after :)
|
|
1593
|
-
if (line.includes(':') && !line.endsWith(':')) {
|
|
1594
|
-
result.push(line);
|
|
1595
|
-
continue;
|
|
1596
|
-
}
|
|
1597
|
-
// Check if this structural element has children with refs
|
|
1598
|
-
const currentIndent = getIndentLevel(line);
|
|
1599
|
-
let hasRelevantChildren = false;
|
|
1600
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
1601
|
-
const childIndent = getIndentLevel(lines[j]);
|
|
1602
|
-
if (childIndent <= currentIndent)
|
|
1603
|
-
break;
|
|
1604
|
-
if (lines[j].includes('[ref=')) {
|
|
1605
|
-
hasRelevantChildren = true;
|
|
1606
|
-
break;
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
if (hasRelevantChildren) {
|
|
1610
|
-
result.push(line);
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
return result.join('\n');
|
|
1614
|
-
}
|
|
1615
|
-
/**
|
|
1616
|
-
* Parse a ref from command argument (e.g., "@e1" -> "e1")
|
|
1617
|
-
*/
|
|
1618
|
-
export function parseRef(arg) {
|
|
1619
|
-
if (arg.startsWith('@')) {
|
|
1620
|
-
return arg.slice(1);
|
|
1621
|
-
}
|
|
1622
|
-
if (arg.startsWith('[ref=') && arg.endsWith(']')) {
|
|
1623
|
-
return arg.slice(5, -1);
|
|
1624
|
-
}
|
|
1625
|
-
if (arg.startsWith('ref=')) {
|
|
1626
|
-
return arg.slice(4);
|
|
1627
|
-
}
|
|
1628
|
-
if (/^e\d+$/.test(arg)) {
|
|
1629
|
-
return arg;
|
|
1630
|
-
}
|
|
1631
|
-
return null;
|
|
1632
|
-
}
|
|
1633
|
-
/**
|
|
1634
|
-
* Get snapshot statistics
|
|
1635
|
-
*/
|
|
1636
|
-
export function getSnapshotStats(tree, refs) {
|
|
1637
|
-
const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length;
|
|
1638
|
-
return {
|
|
1639
|
-
lines: tree.split('\n').length,
|
|
1640
|
-
chars: tree.length,
|
|
1641
|
-
tokens: Math.ceil(tree.length / 4),
|
|
1642
|
-
refs: Object.keys(refs).length,
|
|
1643
|
-
interactive,
|
|
1644
|
-
};
|
|
1645
|
-
}
|
|
1646
|
-
const STYLE_CLASS_PATTERNS = [
|
|
1647
|
-
/^(flex|grid|block|inline|hidden)$/,
|
|
1648
|
-
/^(mt|mb|ml|mr|mx|my|pt|pb|pl|pr|px|py)-?\d*$/,
|
|
1649
|
-
/^(w|h|min|max)-/,
|
|
1650
|
-
/^(text|font|bg|border)-/,
|
|
1651
|
-
/^(rounded|shadow|opacity)-/,
|
|
1652
|
-
/^(hover|focus|active):/,
|
|
1653
|
-
/^(items|justify|gap|space)-/,
|
|
1654
|
-
/^(p|m)-\d*$/,
|
|
1655
|
-
/^transition/,
|
|
1656
|
-
/^duration/,
|
|
1657
|
-
/^ease/,
|
|
1658
|
-
/^transform/,
|
|
1659
|
-
/^scale|rotate|translate/,
|
|
1660
|
-
];
|
|
1661
|
-
const SEMANTIC_TAGS = new Set([
|
|
1662
|
-
'main',
|
|
1663
|
-
'nav',
|
|
1664
|
-
'header',
|
|
1665
|
-
'footer',
|
|
1666
|
-
'article',
|
|
1667
|
-
'section',
|
|
1668
|
-
'aside',
|
|
1669
|
-
'form',
|
|
1670
|
-
]);
|
|
1671
|
-
export function getSemanticClass(element) {
|
|
1672
|
-
const className = element.getAttribute('class');
|
|
1673
|
-
if (!className)
|
|
1674
|
-
return null;
|
|
1675
|
-
const classes = className.split(/\s+/).filter((cls) => {
|
|
1676
|
-
return !STYLE_CLASS_PATTERNS.some((p) => p.test(cls));
|
|
1677
|
-
});
|
|
1678
|
-
if (classes.length === 0)
|
|
1679
|
-
return null;
|
|
1680
|
-
const selectedClasses = classes.slice(0, 2);
|
|
1681
|
-
return selectedClasses.map((cls) => `contains(@class, "${cls}")`).join(' and ');
|
|
1682
|
-
}
|
|
1683
|
-
export function generateXPath(element, maxDepth = 5) {
|
|
1684
|
-
const elemId = element.id;
|
|
1685
|
-
if (elemId) {
|
|
1686
|
-
return `//*[@id="${elemId}"]`;
|
|
1687
|
-
}
|
|
1688
|
-
const testId = element.getAttribute('data-testid');
|
|
1689
|
-
if (testId) {
|
|
1690
|
-
return `//*[@data-testid="${testId}"]`;
|
|
1691
|
-
}
|
|
1692
|
-
const dataId = element.getAttribute('data-id');
|
|
1693
|
-
if (dataId) {
|
|
1694
|
-
return `//*[@data-id="${dataId}"]`;
|
|
1695
|
-
}
|
|
1696
|
-
const semanticClass = getSemanticClass(element);
|
|
1697
|
-
if (semanticClass) {
|
|
1698
|
-
return `//${element.tagName.toLowerCase()}[${semanticClass}]`;
|
|
1699
|
-
}
|
|
1700
|
-
return buildRelativeXPath(element, maxDepth);
|
|
1701
|
-
}
|
|
1702
|
-
function buildRelativeXPath(element, maxDepth) {
|
|
1703
|
-
const path = [];
|
|
1704
|
-
let current = element;
|
|
1705
|
-
let depth = 0;
|
|
1706
|
-
while (current && depth < maxDepth) {
|
|
1707
|
-
const currentId = current.id;
|
|
1708
|
-
if (currentId) {
|
|
1709
|
-
path.unshift(`//*[@id="${currentId}"]`);
|
|
1710
|
-
break;
|
|
1711
|
-
}
|
|
1712
|
-
const testId = current.getAttribute('data-testid');
|
|
1713
|
-
if (testId) {
|
|
1714
|
-
path.unshift(`//*[@data-testid="${testId}"]`);
|
|
1715
|
-
break;
|
|
1716
|
-
}
|
|
1717
|
-
const tagName = current.tagName.toLowerCase();
|
|
1718
|
-
if (SEMANTIC_TAGS.has(tagName)) {
|
|
1719
|
-
const index = getElementIndex(current);
|
|
1720
|
-
path.unshift(`//${tagName}[${index}]`);
|
|
1721
|
-
break;
|
|
1722
|
-
}
|
|
1723
|
-
const index = getElementIndex(current);
|
|
1724
|
-
path.unshift(`${tagName}[${index}]`);
|
|
1725
|
-
current = current.parentElement;
|
|
1726
|
-
depth++;
|
|
1727
|
-
}
|
|
1728
|
-
if (path.length > 0 && !path[0].startsWith('//')) {
|
|
1729
|
-
path.unshift('//');
|
|
1730
|
-
}
|
|
1731
|
-
return path.join('/');
|
|
1732
|
-
}
|
|
1733
|
-
function getElementIndex(element) {
|
|
1734
|
-
const parent = element.parentElement;
|
|
1735
|
-
if (!parent)
|
|
1736
|
-
return 1;
|
|
1737
|
-
const siblings = Array.from(parent.children).filter((child) => child.tagName === element.tagName);
|
|
1738
|
-
return siblings.indexOf(element) + 1;
|
|
1739
|
-
}
|
|
1740
|
-
export function generateCSSPath(element, maxDepth = 5) {
|
|
1741
|
-
const elemId = element.id;
|
|
1742
|
-
if (elemId) {
|
|
1743
|
-
return `#${elemId}`;
|
|
1744
|
-
}
|
|
1745
|
-
const testId = element.getAttribute('data-testid');
|
|
1746
|
-
if (testId) {
|
|
1747
|
-
return `[data-testid="${testId}"]`;
|
|
1748
|
-
}
|
|
1749
|
-
const path = [];
|
|
1750
|
-
let current = element;
|
|
1751
|
-
let depth = 0;
|
|
1752
|
-
while (current && depth < maxDepth) {
|
|
1753
|
-
const currentId = current.id;
|
|
1754
|
-
if (currentId) {
|
|
1755
|
-
path.unshift(`#${currentId}`);
|
|
1756
|
-
break;
|
|
1757
|
-
}
|
|
1758
|
-
const testId = current.getAttribute('data-testid');
|
|
1759
|
-
if (testId) {
|
|
1760
|
-
path.unshift(`[data-testid="${testId}"]`);
|
|
1761
|
-
break;
|
|
1762
|
-
}
|
|
1763
|
-
const tagName = current.tagName.toLowerCase();
|
|
1764
|
-
if (SEMANTIC_TAGS.has(tagName)) {
|
|
1765
|
-
path.unshift(tagName);
|
|
1766
|
-
break;
|
|
1767
|
-
}
|
|
1768
|
-
const selector = buildElementSelector(current);
|
|
1769
|
-
path.unshift(selector);
|
|
1770
|
-
current = current.parentElement;
|
|
1771
|
-
depth++;
|
|
1772
|
-
}
|
|
1773
|
-
return path.join(' > ');
|
|
1774
|
-
}
|
|
1775
|
-
function buildElementSelector(element) {
|
|
1776
|
-
const tagName = element.tagName.toLowerCase();
|
|
1777
|
-
const className = element.getAttribute('class');
|
|
1778
|
-
if (className) {
|
|
1779
|
-
const classes = className.split(/\s+/).filter((cls) => {
|
|
1780
|
-
return !STYLE_CLASS_PATTERNS.some((p) => p.test(cls));
|
|
1781
|
-
});
|
|
1782
|
-
if (classes.length > 0) {
|
|
1783
|
-
return `${tagName}.${classes.slice(0, 2).join('.')}`;
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
const parent = element.parentElement;
|
|
1787
|
-
if (parent) {
|
|
1788
|
-
const index = Array.from(parent.children).indexOf(element) + 1;
|
|
1789
|
-
return `${tagName}:nth-child(${index})`;
|
|
1790
|
-
}
|
|
1791
|
-
return tagName;
|
|
1792
|
-
}
|
|
1793
|
-
export function collectAttributes(element) {
|
|
1794
|
-
const attrs = {};
|
|
1795
|
-
for (let i = 0; i < element.attributes.length; i++) {
|
|
1796
|
-
const attr = element.attributes[i];
|
|
1797
|
-
attrs[attr.name] = attr.value;
|
|
1798
|
-
}
|
|
1799
|
-
return attrs;
|
|
1800
|
-
}
|
|
1
|
+
export * from './snapshot/index.js';
|
|
1801
2
|
//# sourceMappingURL=snapshot.js.map
|