@zibby/mcp-browser 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/LICENSE +21 -0
- package/README.md +108 -0
- package/bin/mcp-browser-zibby.js +606 -0
- package/lib/browser/config.js +369 -0
- package/lib/browser/context.js +274 -0
- package/lib/browser/tab.js +301 -0
- package/lib/browser/tools/common.js +67 -0
- package/lib/browser/tools/keyboard.js +88 -0
- package/lib/browser/tools/navigate.js +66 -0
- package/lib/browser/tools/snapshot.js +197 -0
- package/lib/sdk/bundle.js +84 -0
- package/package.json +45 -0
- package/src/index.mjs +594 -0
- package/src/stable-id-inject.js +347 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable ID Injection Script
|
|
3
|
+
* Injected into every page via --init-script to add data-zibby-id attributes
|
|
4
|
+
*
|
|
5
|
+
* DESIGN GOAL: 100% reliable stable IDs without relying on data-testid
|
|
6
|
+
*
|
|
7
|
+
* Strategy:
|
|
8
|
+
* 1. Use element's intrinsic attributes (id, name, type, etc.)
|
|
9
|
+
* 2. Use element's semantic label (aria-label, associated label, text content)
|
|
10
|
+
* 3. Use element's semantic context (which form, which section, which landmark)
|
|
11
|
+
* 4. Hash everything for a unique, stable ID
|
|
12
|
+
*/
|
|
13
|
+
(function() {
|
|
14
|
+
if (window.__zibbyInitialized) return;
|
|
15
|
+
window.__zibbyInitialized = true;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the semantic label for an element
|
|
19
|
+
* This is what a user would call the element
|
|
20
|
+
*/
|
|
21
|
+
function getSemanticLabel(el) {
|
|
22
|
+
// 1. aria-label is explicit
|
|
23
|
+
if (el.getAttribute('aria-label')) return el.getAttribute('aria-label').trim();
|
|
24
|
+
|
|
25
|
+
// 2. aria-labelledby
|
|
26
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
27
|
+
if (labelledBy) {
|
|
28
|
+
const labelEl = document.getElementById(labelledBy);
|
|
29
|
+
if (labelEl) return labelEl.textContent.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 3. For form fields, check associated <label>
|
|
33
|
+
if (el.id) {
|
|
34
|
+
const label = document.querySelector(`label[for="${el.id}"]`);
|
|
35
|
+
if (label) return label.textContent.trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 4. Check if wrapped in <label>
|
|
39
|
+
const parentLabel = el.closest('label');
|
|
40
|
+
if (parentLabel) {
|
|
41
|
+
// Get label text without the input's value
|
|
42
|
+
const clone = parentLabel.cloneNode(true);
|
|
43
|
+
clone.querySelectorAll('input, select, textarea').forEach(e => e.remove());
|
|
44
|
+
const text = clone.textContent.trim();
|
|
45
|
+
if (text) return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 5. placeholder for inputs
|
|
49
|
+
if (el.placeholder) return el.placeholder;
|
|
50
|
+
|
|
51
|
+
// 6. Text content for buttons/links
|
|
52
|
+
const tag = el.tagName.toLowerCase();
|
|
53
|
+
if (tag === 'button' || tag === 'a' || el.getAttribute('role') === 'button') {
|
|
54
|
+
return (el.textContent || '').trim().slice(0, 50);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 7. title attribute
|
|
58
|
+
if (el.title) return el.title;
|
|
59
|
+
|
|
60
|
+
// 8. value for submit buttons
|
|
61
|
+
if (tag === 'input' && (el.type === 'submit' || el.type === 'button')) {
|
|
62
|
+
return el.value || '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get semantic context - which form/section/landmark the element is in
|
|
70
|
+
* This distinguishes elements with same attributes in different contexts
|
|
71
|
+
*/
|
|
72
|
+
function getSemanticContext(el) {
|
|
73
|
+
const context = [];
|
|
74
|
+
|
|
75
|
+
// 1. Form context (most important for form elements)
|
|
76
|
+
const form = el.closest('form');
|
|
77
|
+
if (form) {
|
|
78
|
+
// Use form's identity
|
|
79
|
+
if (form.id) context.push('form#' + form.id);
|
|
80
|
+
else if (form.name) context.push('form[name=' + form.name + ']');
|
|
81
|
+
else if (form.action) {
|
|
82
|
+
try {
|
|
83
|
+
const action = new URL(form.action, window.location.origin).pathname;
|
|
84
|
+
context.push('form[action=' + action + ']');
|
|
85
|
+
} catch {
|
|
86
|
+
context.push('form[action=' + form.getAttribute('action') + ']');
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
// Count which form this is on the page
|
|
90
|
+
const forms = document.querySelectorAll('form');
|
|
91
|
+
const index = Array.from(forms).indexOf(form);
|
|
92
|
+
context.push('form:nth(' + index + ')');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Landmark context (header, nav, main, footer, aside)
|
|
97
|
+
const landmark = el.closest('header, nav, main, footer, aside, [role="banner"], [role="navigation"], [role="main"], [role="contentinfo"]');
|
|
98
|
+
if (landmark) {
|
|
99
|
+
const tag = landmark.tagName.toLowerCase();
|
|
100
|
+
const role = landmark.getAttribute('role');
|
|
101
|
+
context.push(role || tag);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. Section with heading
|
|
105
|
+
const section = el.closest('section, article, [role="region"]');
|
|
106
|
+
if (section) {
|
|
107
|
+
const heading = section.querySelector('h1, h2, h3, h4, h5, h6');
|
|
108
|
+
if (heading) {
|
|
109
|
+
context.push('section:' + heading.textContent.trim().slice(0, 30));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 4. Fieldset context
|
|
114
|
+
const fieldset = el.closest('fieldset');
|
|
115
|
+
if (fieldset) {
|
|
116
|
+
const legend = fieldset.querySelector('legend');
|
|
117
|
+
if (legend) {
|
|
118
|
+
context.push('fieldset:' + legend.textContent.trim());
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 5. Dialog/modal context
|
|
123
|
+
const dialog = el.closest('dialog, [role="dialog"], [role="alertdialog"]');
|
|
124
|
+
if (dialog) {
|
|
125
|
+
const title = dialog.querySelector('[role="heading"], h1, h2, h3');
|
|
126
|
+
if (title) context.push('dialog:' + title.textContent.trim().slice(0, 30));
|
|
127
|
+
else context.push('dialog');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return context.join('/');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Compute stable ID for an element
|
|
135
|
+
* Combines: tag, attributes, semantic label, semantic context
|
|
136
|
+
*/
|
|
137
|
+
function computeStableId(el) {
|
|
138
|
+
const tag = el.tagName.toLowerCase();
|
|
139
|
+
const id = el.id || '';
|
|
140
|
+
const name = el.name || '';
|
|
141
|
+
const type = el.type || '';
|
|
142
|
+
const role = el.getAttribute('role') || '';
|
|
143
|
+
|
|
144
|
+
// Safely extract href pathname
|
|
145
|
+
let href = '';
|
|
146
|
+
if (el.href) {
|
|
147
|
+
try {
|
|
148
|
+
href = new URL(el.href, window.location.origin).pathname.slice(0, 50);
|
|
149
|
+
} catch {
|
|
150
|
+
// Fallback if URL construction fails (e.g., about:blank pages)
|
|
151
|
+
href = el.getAttribute('href')?.slice(0, 50) || '';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Get semantic label (what user calls this element)
|
|
156
|
+
const label = getSemanticLabel(el).slice(0, 50).replace(/\s+/g, ' ');
|
|
157
|
+
|
|
158
|
+
// Get semantic context (where this element is)
|
|
159
|
+
const context = getSemanticContext(el);
|
|
160
|
+
|
|
161
|
+
// Build signature with all identifying info
|
|
162
|
+
const sig = [
|
|
163
|
+
tag,
|
|
164
|
+
id,
|
|
165
|
+
name,
|
|
166
|
+
type,
|
|
167
|
+
role,
|
|
168
|
+
href,
|
|
169
|
+
label,
|
|
170
|
+
context
|
|
171
|
+
].join('|');
|
|
172
|
+
|
|
173
|
+
// djb2 hash - fast and good distribution
|
|
174
|
+
let hash = 5381;
|
|
175
|
+
for (let i = 0; i < sig.length; i++) {
|
|
176
|
+
hash = ((hash << 5) + hash) ^ sig.charCodeAt(i);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return 'zibby-' + (hash >>> 0).toString(36);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function injectStableIds() {
|
|
183
|
+
if (!document.body) return;
|
|
184
|
+
|
|
185
|
+
const selectors = [
|
|
186
|
+
'button', 'a', 'input', 'select', 'textarea',
|
|
187
|
+
'label[for]', // Labels that control inputs
|
|
188
|
+
'[role="button"]', '[role="link"]', '[role="textbox"]',
|
|
189
|
+
'[role="checkbox"]', '[role="radio"]', '[role="combobox"]',
|
|
190
|
+
'[role="menuitem"]', '[role="tab"]', '[role="option"]',
|
|
191
|
+
'[role="switch"]', '[role="slider"]',
|
|
192
|
+
'[onclick]', '[data-action]'
|
|
193
|
+
].join(', ');
|
|
194
|
+
|
|
195
|
+
const idCounts = new Map();
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
document.querySelectorAll(selectors).forEach((el) => {
|
|
199
|
+
// Skip hidden elements
|
|
200
|
+
const style = window.getComputedStyle(el);
|
|
201
|
+
if (style.display === 'none' || style.visibility === 'hidden') return;
|
|
202
|
+
|
|
203
|
+
let stableId = computeStableId(el);
|
|
204
|
+
|
|
205
|
+
// Handle duplicates (same ID means truly identical elements)
|
|
206
|
+
const baseId = stableId;
|
|
207
|
+
const count = idCounts.get(baseId) || 0;
|
|
208
|
+
if (count > 0) stableId = baseId + '-' + count;
|
|
209
|
+
idCounts.set(baseId, count + 1);
|
|
210
|
+
|
|
211
|
+
el.setAttribute('data-zibby-id', stableId);
|
|
212
|
+
});
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.error('[Zibby] Error injecting stable IDs:', e);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function initialize() {
|
|
219
|
+
if (!document.body) {
|
|
220
|
+
if (document.readyState === 'loading') {
|
|
221
|
+
document.addEventListener('DOMContentLoaded', initialize);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
injectStableIds();
|
|
227
|
+
|
|
228
|
+
// Re-inject on DOM changes (for SPAs)
|
|
229
|
+
const observer = new MutationObserver(() => {
|
|
230
|
+
requestAnimationFrame(injectStableIds);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (document.body) {
|
|
234
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Expose helper for getting stable ID
|
|
239
|
+
window.__zibbyGetStableId = function(element) {
|
|
240
|
+
return element?.getAttribute('data-zibby-id') || null;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Track last interacted element - use cookie to persist across subdomains!
|
|
244
|
+
// Cookie approach works for: login.example.com -> hr.example.com
|
|
245
|
+
function getBaseDomain() {
|
|
246
|
+
const parts = window.location.hostname.split('.');
|
|
247
|
+
// Get root domain (e.g., example.com from app.example.com)
|
|
248
|
+
return parts.length >= 2 ? '.' + parts.slice(-2).join('.') : window.location.hostname;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
Object.defineProperty(window, '__zibbyLastStableId', {
|
|
252
|
+
get: function() {
|
|
253
|
+
const match = document.cookie.match(/(?:^|; )__zibbyLastStableId=([^;]*)/);
|
|
254
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
255
|
+
},
|
|
256
|
+
set: function(value) {
|
|
257
|
+
if (value) {
|
|
258
|
+
// Set cookie with domain to share across subdomains, expires in 1 hour
|
|
259
|
+
const domain = getBaseDomain();
|
|
260
|
+
document.cookie = `__zibbyLastStableId=${encodeURIComponent(value)}; domain=${domain}; path=/; max-age=3600; SameSite=Lax`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Find the actual interactive element from an event target
|
|
267
|
+
* Handles: label clicks that activate inputs, etc.
|
|
268
|
+
*/
|
|
269
|
+
function findInteractiveElement(target) {
|
|
270
|
+
// 1. Direct match
|
|
271
|
+
const el = target.closest('[data-zibby-id]');
|
|
272
|
+
if (el) return el;
|
|
273
|
+
|
|
274
|
+
// 2. If clicking a label, find its associated input
|
|
275
|
+
const label = target.closest('label');
|
|
276
|
+
if (label) {
|
|
277
|
+
// Check for explicit "for" attribute
|
|
278
|
+
if (label.htmlFor) {
|
|
279
|
+
const input = document.getElementById(label.htmlFor);
|
|
280
|
+
if (input?.getAttribute('data-zibby-id')) return input;
|
|
281
|
+
}
|
|
282
|
+
// Check for nested input
|
|
283
|
+
const nestedInput = label.querySelector('input, select, textarea, [role="checkbox"], [role="radio"]');
|
|
284
|
+
if (nestedInput?.getAttribute('data-zibby-id')) return nestedInput;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 3. Check if we're inside a custom component that wraps an input
|
|
288
|
+
const parent = target.closest('[class*="select"], [class*="dropdown"], [class*="checkbox"], [class*="radio"]');
|
|
289
|
+
if (parent) {
|
|
290
|
+
const input = parent.querySelector('[data-zibby-id]');
|
|
291
|
+
if (input) return input;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get stable ID for an element - either existing or computed on-the-fly
|
|
299
|
+
* This handles race conditions where IDs haven't been injected yet
|
|
300
|
+
*/
|
|
301
|
+
function getOrComputeStableId(el) {
|
|
302
|
+
if (!el) return null;
|
|
303
|
+
|
|
304
|
+
// 1. Already has stable ID
|
|
305
|
+
const existingId = el.getAttribute('data-zibby-id');
|
|
306
|
+
if (existingId) return existingId;
|
|
307
|
+
|
|
308
|
+
// 2. Compute on-the-fly and inject
|
|
309
|
+
const stableId = computeStableId(el);
|
|
310
|
+
el.setAttribute('data-zibby-id', stableId);
|
|
311
|
+
return stableId;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
document.addEventListener('click', (e) => {
|
|
315
|
+
// Try to find element with existing ID first
|
|
316
|
+
let target = findInteractiveElement(e.target);
|
|
317
|
+
|
|
318
|
+
// If no match, compute ID for the clicked element itself
|
|
319
|
+
if (!target && e.target.matches('button, a, input, select, textarea, [role="button"], [onclick]')) {
|
|
320
|
+
target = e.target;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (target) {
|
|
324
|
+
window.__zibbyLastStableId = getOrComputeStableId(target);
|
|
325
|
+
}
|
|
326
|
+
}, true);
|
|
327
|
+
|
|
328
|
+
document.addEventListener('focusin', (e) => {
|
|
329
|
+
let target = findInteractiveElement(e.target);
|
|
330
|
+
if (!target && e.target.matches('input, select, textarea, [role="textbox"], [role="combobox"]')) {
|
|
331
|
+
target = e.target;
|
|
332
|
+
}
|
|
333
|
+
if (target) {
|
|
334
|
+
window.__zibbyLastStableId = getOrComputeStableId(target);
|
|
335
|
+
}
|
|
336
|
+
}, true);
|
|
337
|
+
|
|
338
|
+
document.addEventListener('change', (e) => {
|
|
339
|
+
let target = findInteractiveElement(e.target);
|
|
340
|
+
if (!target) target = e.target;
|
|
341
|
+
if (target) {
|
|
342
|
+
window.__zibbyLastStableId = getOrComputeStableId(target);
|
|
343
|
+
}
|
|
344
|
+
}, true);
|
|
345
|
+
|
|
346
|
+
initialize();
|
|
347
|
+
})();
|