@zibby/mcp-browser 0.1.0 → 0.1.6

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/src/index.mjs DELETED
@@ -1,594 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Zibby MCP Browser
4
- *
5
- * Uses official @playwright/mcp with:
6
- * - Stable ID injection via init script
7
- * - Event recording with stable IDs
8
- */
9
-
10
- import { createConnection } from '@playwright/mcp';
11
- import { chromium } from 'playwright';
12
- import { execSync } from 'child_process';
13
- import fs from 'fs';
14
- import path from 'path';
15
- import { SESSION_INFO_FILE } from '@zibby/core';
16
-
17
- // Event recording
18
- let nodeSessionPath = null;
19
- const events = [];
20
- let videoStartTime = null;
21
-
22
- function loadSessionInfo(outputDir) {
23
- try {
24
- if (!outputDir) {
25
- console.error(`[Zibby MCP] ⚠️ No output directory provided - skipping session info`);
26
- return;
27
- }
28
-
29
- const sessionInfoPath = path.join(outputDir, '../..', SESSION_INFO_FILE);
30
- if (fs.existsSync(sessionInfoPath)) {
31
- const sessionInfo = JSON.parse(fs.readFileSync(sessionInfoPath, 'utf-8'));
32
- const nodeName = sessionInfo.currentNode || 'execute_live';
33
-
34
- if (sessionInfo.sessionPath) {
35
- nodeSessionPath = path.join(sessionInfo.sessionPath, nodeName);
36
- fs.mkdirSync(nodeSessionPath, { recursive: true });
37
- console.error(`[Zibby MCP] 📁 Recording to: ${nodeSessionPath}`);
38
- }
39
- }
40
- } catch (err) {
41
- console.error(`[Zibby MCP] ⚠️ Session info: ${err.message}`);
42
- }
43
- }
44
-
45
- // Format milliseconds to time
46
- function formatTime(ms) {
47
- const totalSeconds = Math.floor(ms / 1000);
48
- const hours = Math.floor(totalSeconds / 3600);
49
- const minutes = Math.floor((totalSeconds % 3600) / 60);
50
- const seconds = totalSeconds % 60;
51
- const milliseconds = ms % 1000;
52
- return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}`;
53
- }
54
-
55
- /**
56
- * Parse WebM file to extract duration (fallback when ffprobe not available)
57
- */
58
- function parseWebMDuration(buffer) {
59
- const SEGMENT_ID = 0x18538067;
60
- const INFO_ID = 0x1549A966;
61
- const DURATION_ID = 0x4489;
62
- const TIMECODE_SCALE_ID = 0x2AD7B1;
63
-
64
- function readVINT(buffer, pos, keepMarker) {
65
- if (pos >= buffer.length) return null;
66
- const first = buffer[pos];
67
- let length = 1;
68
- let mask = 0x80;
69
- while (length <= 8 && !(first & mask)) {
70
- length++;
71
- mask >>= 1;
72
- }
73
- if (length > 8 || pos + length > buffer.length) return null;
74
- let value = keepMarker ? first : (first & (mask - 1));
75
- for (let i = 1; i < length; i++) {
76
- value = (value << 8) | buffer[pos + i];
77
- }
78
- return { value, length };
79
- }
80
-
81
- function readEBMLElement(buffer, pos) {
82
- if (pos >= buffer.length) return null;
83
- const idResult = readVINT(buffer, pos, true);
84
- if (!idResult) return null;
85
- pos += idResult.length;
86
- const sizeResult = readVINT(buffer, pos, false);
87
- if (!sizeResult) return null;
88
- pos += sizeResult.length;
89
- return {
90
- id: idResult.value,
91
- dataStart: pos,
92
- dataSize: sizeResult.value,
93
- dataEnd: pos + sizeResult.value
94
- };
95
- }
96
-
97
- function readUInt(buffer, pos, size) {
98
- let value = 0;
99
- for (let i = 0; i < size; i++) {
100
- value = (value << 8) | buffer[pos + i];
101
- }
102
- return value;
103
- }
104
-
105
- function readFloat(buffer, pos, size) {
106
- if (size === 4) return buffer.readFloatBE(pos);
107
- if (size === 8) return buffer.readDoubleBE(pos);
108
- return null;
109
- }
110
-
111
- let pos = 0;
112
- let timecodeScale = 1000000;
113
- let duration = null;
114
-
115
- const ebmlHeader = readEBMLElement(buffer, pos);
116
- if (!ebmlHeader) return null;
117
- pos = ebmlHeader.dataEnd;
118
-
119
- const segment = readEBMLElement(buffer, pos);
120
- if (!segment || segment.id !== SEGMENT_ID) return null;
121
-
122
- pos = segment.dataStart;
123
- const segmentEnd = Math.min(segment.dataEnd, buffer.length);
124
-
125
- while (pos < segmentEnd) {
126
- const element = readEBMLElement(buffer, pos);
127
- if (!element) break;
128
-
129
- if (element.id === INFO_ID) {
130
- let infoPos = element.dataStart;
131
- while (infoPos < element.dataEnd) {
132
- const infoElement = readEBMLElement(buffer, infoPos);
133
- if (!infoElement) break;
134
- if (infoElement.id === TIMECODE_SCALE_ID) {
135
- timecodeScale = readUInt(buffer, infoElement.dataStart, infoElement.dataSize);
136
- } else if (infoElement.id === DURATION_ID) {
137
- duration = readFloat(buffer, infoElement.dataStart, infoElement.dataSize);
138
- }
139
- infoPos = infoElement.dataEnd;
140
- }
141
- break;
142
- }
143
- pos = element.dataEnd;
144
- }
145
-
146
- if (duration !== null) {
147
- return (duration * timecodeScale) / 1000000;
148
- }
149
- return null;
150
- }
151
-
152
- /**
153
- * Get video duration in milliseconds using ffprobe or WebM parser fallback
154
- */
155
- function getVideoDurationMs(videoPath) {
156
- // Try ffprobe first (most accurate)
157
- try {
158
- const result = execSync(
159
- `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${videoPath}"`,
160
- { encoding: 'utf-8', timeout: 5000 }
161
- );
162
- const durationSec = parseFloat(result.trim());
163
- if (!isNaN(durationSec)) {
164
- const durationMs = Math.round(durationSec * 1000);
165
- console.error(`[Zibby MCP] 📏 Video duration (ffprobe): ${durationMs}ms`);
166
- return durationMs;
167
- }
168
- } catch (_e) {
169
- console.error(`[Zibby MCP] ⚠️ ffprobe not available, using WebM parser fallback`);
170
- }
171
-
172
- // Fallback: Parse WebM file directly
173
- try {
174
- const buffer = fs.readFileSync(videoPath);
175
- const duration = parseWebMDuration(buffer);
176
- if (duration) {
177
- const durationMs = Math.round(duration);
178
- console.error(`[Zibby MCP] 📏 Video duration (WebM parser): ${durationMs}ms`);
179
- return durationMs;
180
- }
181
- } catch (_e) {
182
- console.error(`[Zibby MCP] ⚠️ WebM parsing failed: ${_e.message}`);
183
- }
184
-
185
- return null;
186
- }
187
-
188
- /**
189
- * Recalculate all event timestamps after browser close using actual video duration
190
- */
191
- function recalculateTimestampsFromVideo() {
192
- if (!nodeSessionPath || events.length === 0) return;
193
-
194
- // Find video file in current node directory (Playwright creates page-*.webm files)
195
- let videoPath = null;
196
- let attempts = 0;
197
- const maxAttempts = 40; // 40 attempts * 500ms = 20 seconds max
198
- let lastSize = 0;
199
- let stableCount = 0;
200
-
201
- while (!videoPath && attempts < maxAttempts) {
202
- attempts++;
203
-
204
- // Look for either recording.webm or page-*.webm files
205
- let candidatePath = null;
206
- if (fs.existsSync(path.join(nodeSessionPath, 'recording.webm'))) {
207
- candidatePath = path.join(nodeSessionPath, 'recording.webm');
208
- } else {
209
- // Look for Playwright's page-*.webm pattern
210
- try {
211
- const files = fs.readdirSync(nodeSessionPath);
212
- const webmFiles = files.filter(f => f.endsWith('.webm'));
213
- if (webmFiles.length > 0) {
214
- candidatePath = path.join(nodeSessionPath, webmFiles[0]);
215
- }
216
- } catch (_e) {
217
- // Directory might not be ready
218
- console.error(`[Zibby MCP] ⚠️ Error reading directory: ${_e.message}`);
219
- }
220
- }
221
-
222
- // If we found a file, check if it's stable (not still being written)
223
- if (candidatePath && fs.existsSync(candidatePath)) {
224
- try {
225
- const stats = fs.statSync(candidatePath);
226
- const currentSize = stats.size;
227
-
228
- if (currentSize > 0 && currentSize === lastSize) {
229
- stableCount++;
230
- if (stableCount >= 2) { // File size unchanged for 2 checks (1 second)
231
- videoPath = candidatePath;
232
- console.error(`[Zibby MCP] ✅ Video file stable after ${attempts} attempts: ${path.basename(videoPath)} (${currentSize} bytes)`);
233
- break;
234
- }
235
- } else {
236
- stableCount = 0;
237
- lastSize = currentSize;
238
- }
239
- } catch (_e) {
240
- console.error(`[Zibby MCP] ⚠️ Error checking file: ${_e.message}`);
241
- }
242
- }
243
-
244
- if (!videoPath) {
245
- if (attempts % 5 === 0) { // Log every 2.5 seconds
246
- console.error(`[Zibby MCP] ⏳ Waiting for stable video file... (attempt ${attempts}/${maxAttempts}, size: ${lastSize})`);
247
- }
248
- // Blocking sleep
249
- const start = Date.now();
250
- while (Date.now() - start < 500) { /* 500ms sleep */ }
251
- }
252
- }
253
-
254
- if (!videoPath) {
255
- console.error(`[Zibby MCP] ⚠️ Video file not found/stable after ${attempts} attempts in: ${nodeSessionPath}`);
256
- saveEvents();
257
- return;
258
- }
259
-
260
- // Get video duration
261
- const videoDurationMs = getVideoDurationMs(videoPath);
262
- if (!videoDurationMs) {
263
- console.error(`[Zibby MCP] ⚠️ Could not determine video duration, using fallback`);
264
- saveEvents();
265
- return;
266
- }
267
-
268
- // Find close event
269
- const closeEvent = events.find(e => e.type === 'close');
270
- if (!closeEvent) {
271
- console.error(`[Zibby MCP] ⚠️ No close event found`);
272
- saveEvents();
273
- return;
274
- }
275
-
276
- // Calculate accurate video start time
277
- const closeTimestamp = new Date(closeEvent.timestamp).getTime();
278
- const calculatedVideoStartMs = closeTimestamp - videoDurationMs;
279
-
280
- console.error(`[Zibby MCP] 📊 Calculated video start: ${new Date(calculatedVideoStartMs).toISOString()}`);
281
- console.error(`[Zibby MCP] Close timestamp: ${closeTimestamp}ms, Video duration: ${videoDurationMs}ms`);
282
-
283
- // Recalculate all event timestamps
284
- events.forEach(event => {
285
- const eventTimestamp = new Date(event.timestamp).getTime();
286
- event.videoOffsetMs = eventTimestamp - calculatedVideoStartMs;
287
- event.videoOffsetFormatted = formatTime(event.videoOffsetMs);
288
- });
289
-
290
- console.error(`[Zibby MCP] ✅ Recalculated ${events.length} events using accurate video start time`);
291
- saveEvents();
292
- }
293
-
294
- function saveEvents() {
295
- if (!nodeSessionPath) return;
296
- try {
297
- const eventsPath = path.join(nodeSessionPath, 'events.json');
298
- fs.writeFileSync(eventsPath, JSON.stringify(events, null, 2));
299
- } catch (err) {
300
- console.error(`[Zibby MCP] ❌ Write error: ${err.message}`);
301
- }
302
- }
303
-
304
- // Record an event
305
- function recordEvent(type, data) {
306
- if (!nodeSessionPath) return;
307
-
308
- const timestamp = Date.now();
309
-
310
- // Record events with raw timestamps first (will recalculate on close)
311
- if (!videoStartTime) {
312
- videoStartTime = timestamp;
313
- }
314
-
315
- const videoOffsetMs = timestamp - videoStartTime;
316
-
317
- const event = {
318
- id: events.length,
319
- type,
320
- timestamp: new Date(timestamp).toISOString(),
321
- videoOffsetMs,
322
- videoOffsetFormatted: formatTime(videoOffsetMs),
323
- data
324
- };
325
-
326
- events.push(event);
327
- console.error(`[Zibby MCP] 📝 Event #${event.id}: ${type}${data.stableId ? ` (${data.stableId})` : ''}`);
328
-
329
- // If this is a close event, recalculate all timestamps using video duration
330
- if (type === 'close') {
331
- recalculateTimestampsFromVideo();
332
- } else {
333
- saveEvents();
334
- }
335
- }
336
-
337
- // Stable ID injection script
338
- // Ripple effect for visual feedback during AI test execution
339
- const RIPPLE_EFFECT_SCRIPT = `
340
- const style = document.createElement('style');
341
- style.textContent = \`
342
- @keyframes zibby-ripple {
343
- 0% {
344
- transform: scale(0);
345
- opacity: 0.7;
346
- }
347
- 100% {
348
- transform: scale(4);
349
- opacity: 0;
350
- }
351
- }
352
- .zibby-ripple {
353
- position: absolute;
354
- border-radius: 50%;
355
- background: rgba(59, 130, 246, 0.7);
356
- pointer-events: none;
357
- animation: zibby-ripple 0.6s ease-out;
358
- z-index: 999999;
359
- }
360
- \`;
361
-
362
- document.addEventListener('DOMContentLoaded', () => {
363
- if (document.head && !document.getElementById('zibby-ripple-style')) {
364
- style.id = 'zibby-ripple-style';
365
- document.head.appendChild(style);
366
- }
367
- });
368
-
369
- if (document.head && !document.getElementById('zibby-ripple-style')) {
370
- style.id = 'zibby-ripple-style';
371
- document.head.appendChild(style);
372
- }
373
-
374
- window.__zibbyShowRipple = function(x, y) {
375
- const ripple = document.createElement('div');
376
- ripple.className = 'zibby-ripple';
377
- ripple.style.left = (x - 10) + 'px';
378
- ripple.style.top = (y - 10) + 'px';
379
- ripple.style.width = '20px';
380
- ripple.style.height = '20px';
381
-
382
- if (document.body) {
383
- document.body.appendChild(ripple);
384
- setTimeout(() => ripple.remove(), 600);
385
- }
386
- };
387
-
388
- // Auto-show ripple on all clicks (for Playwright/MCP automation)
389
- document.addEventListener('click', function(e) {
390
- window.__zibbyShowRipple(e.clientX, e.clientY);
391
- }, true);
392
- `;
393
-
394
- const STABLE_ID_SCRIPT = `
395
- (function() {
396
- if (window.__zibbyInitialized) return;
397
- window.__zibbyInitialized = true;
398
-
399
- function computeStableId(el) {
400
- const tag = el.tagName.toLowerCase();
401
- const id = el.id || '';
402
- const name = el.name || '';
403
- const type = el.type || '';
404
- const role = el.getAttribute('role') || '';
405
- const ariaLabel = el.getAttribute('aria-label') || '';
406
- const placeholder = el.placeholder || '';
407
- const href = el.href ? new URL(el.href, window.location.origin).pathname.slice(0, 30) : '';
408
-
409
- const sig = [tag, id, name, type, role, ariaLabel, placeholder, href].join('|');
410
-
411
- try {
412
- const encoded = btoa(sig).replace(/[+/=]/g, '').slice(0, 24);
413
- return 'zibby-' + encoded;
414
- } catch (e) {
415
- let hash = 0;
416
- for (let i = 0; i < sig.length; i++) {
417
- hash = ((hash << 5) - hash) + sig.charCodeAt(i);
418
- hash = hash & hash;
419
- }
420
- return 'zibby-' + Math.abs(hash).toString(36);
421
- }
422
- }
423
-
424
- function injectStableIds() {
425
- if (!document.body) return;
426
-
427
- const selectors = [
428
- 'button', 'a', 'input', 'select', 'textarea',
429
- '[role="button"]', '[role="link"]', '[role="textbox"]',
430
- '[role="checkbox"]', '[role="radio"]', '[role="combobox"]',
431
- '[role="menuitem"]', '[role="tab"]', '[role="option"]'
432
- ].join(', ');
433
-
434
- const idCounts = new Map();
435
-
436
- try {
437
- document.querySelectorAll(selectors).forEach((el) => {
438
- let stableId = computeStableId(el);
439
-
440
- const baseId = stableId;
441
- const count = idCounts.get(baseId) || 0;
442
- if (count > 0) stableId = baseId + '-' + count;
443
- idCounts.set(baseId, count + 1);
444
-
445
- el.setAttribute('data-zibby-id', stableId);
446
- });
447
- } catch (e) {
448
- console.warn('[Zibby] Stable ID injection error:', e.message);
449
- }
450
- }
451
-
452
- function initialize() {
453
- if (!document.body) {
454
- if (document.readyState === 'loading') {
455
- document.addEventListener('DOMContentLoaded', initialize);
456
- return;
457
- }
458
- }
459
-
460
- injectStableIds();
461
-
462
- const observer = new MutationObserver(() => injectStableIds());
463
- if (document.body) {
464
- observer.observe(document.body, { childList: true, subtree: true });
465
- }
466
- }
467
-
468
- window.__zibbyGetStableId = function(element) {
469
- return element?.getAttribute('data-zibby-id') || null;
470
- };
471
-
472
- initialize();
473
- })();
474
- `;
475
-
476
- // Parse args
477
- const args = process.argv.slice(2);
478
- const config = {};
479
-
480
- for (let i = 0; i < args.length; i++) {
481
- const arg = args[i];
482
- if (arg.startsWith('--')) {
483
- const [key, ...valueParts] = arg.slice(2).split('=');
484
- const value = valueParts.join('=') || args[++i] || 'true';
485
-
486
- if (key === 'headless') config.headless = true;
487
- else if (key === 'save-video') config.saveVideo = value;
488
- else if (key === 'viewport-size') config.viewportSize = value;
489
- else if (key === 'output-dir') config.outputDir = value;
490
- }
491
- }
492
-
493
- loadSessionInfo(config.outputDir);
494
-
495
- // Singleton browser instance - reuse across all context requests
496
- let browserInstance = null;
497
- let contextCount = 0;
498
-
499
- // Custom context getter that injects stable IDs
500
- async function contextGetter() {
501
- // Reuse browser instance to prevent spawning 10+ browsers
502
- if (!browserInstance) {
503
- const launchOptions = {
504
- headless: config.headless || false
505
- };
506
- browserInstance = await chromium.launch(launchOptions);
507
- console.error('[Zibby MCP] 🌐 Browser launched (will reuse for all contexts)');
508
- }
509
-
510
- const contextOptions = {};
511
-
512
- // Parse viewport
513
- if (config.viewportSize) {
514
- const [width, height] = config.viewportSize.split('x').map(Number);
515
- contextOptions.viewport = { width, height };
516
- }
517
-
518
- // Enable video recording
519
- if (config.saveVideo) {
520
- const [width, height] = config.saveVideo.split('x').map(Number);
521
- contextOptions.recordVideo = {
522
- dir: config.outputDir || 'test-results',
523
- size: { width, height }
524
- };
525
- }
526
-
527
- const context = await browserInstance.newContext(contextOptions);
528
- contextCount++;
529
- console.error(`[Zibby MCP] 📄 Created context #${contextCount}`);
530
-
531
- // ZIBBY: Inject stable ID script and ripple effects
532
- await context.addInitScript(STABLE_ID_SCRIPT);
533
- await context.addInitScript(RIPPLE_EFFECT_SCRIPT);
534
- console.error('[Zibby MCP] ✅ Stable ID injection + ripple effects enabled');
535
-
536
- // Track pages for event recording
537
- context.on('page', (page) => {
538
- // Record navigation events
539
- page.on('framenavigated', (frame) => {
540
- if (frame === page.mainFrame()) {
541
- recordEvent('navigate', { url: page.url() });
542
- }
543
- });
544
- });
545
-
546
- return context;
547
- }
548
-
549
- // Start the MCP server with our custom context
550
- async function main() {
551
- try {
552
- const connection = await createConnection(config, contextGetter);
553
-
554
- // Intercept tool calls for event recording
555
- const originalHandler = connection._handleToolCall?.bind(connection);
556
- if (originalHandler) {
557
- connection._handleToolCall = async (name, params) => {
558
- const result = await originalHandler(name, params);
559
-
560
- // Record events based on tool name
561
- if (name === 'browser_click') {
562
- // Try to get stable ID from page
563
- const stableId = await getStableIdForRef(params.ref);
564
- recordEvent('click', { element: params.element, ref: params.ref, stableId });
565
- } else if (name === 'browser_type') {
566
- const stableId = await getStableIdForRef(params.ref);
567
- recordEvent('type', { element: params.element, ref: params.ref, text: params.text, stableId });
568
- } else if (name === 'browser_select_option') {
569
- const stableId = await getStableIdForRef(params.ref);
570
- recordEvent('select', { element: params.element, ref: params.ref, values: params.values, stableId });
571
- } else if (name === 'browser_close') {
572
- recordEvent('close', {});
573
- }
574
-
575
- return result;
576
- };
577
- }
578
-
579
- // Start stdio transport
580
- console.error('[Zibby MCP] 🚀 Server started');
581
- } catch (error) {
582
- console.error('[Zibby MCP] ❌ Error:', error.message);
583
- process.exit(1);
584
- }
585
- }
586
-
587
- // Helper to get stable ID (placeholder - needs actual implementation)
588
- async function getStableIdForRef(_ref) {
589
- // This would need access to the page context to query the element
590
- // For now, return null - we'll enhance this
591
- return null;
592
- }
593
-
594
- main();