@zibby/mcp-browser 0.1.5 → 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.
@@ -1,606 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Zibby MCP Browser
4
- *
5
- * Wrapper around official @playwright/mcp that:
6
- * 1. Injects stable IDs via --init-script
7
- * 2. Records events to events.json WITH stable IDs
8
- * 3. Passes through all other functionality unchanged
9
- */
10
-
11
- const { spawn, execFileSync } = require('child_process');
12
- const fs = require('fs');
13
- const path = require('path');
14
-
15
- // ========== Configuration ==========
16
- const STABLE_ID_SCRIPT = path.join(__dirname, '..', 'src', 'stable-id-inject.js');
17
- const DEBUG_LOG = path.join(require('os').tmpdir(), 'zibby-mcp-debug.log');
18
-
19
- function debug(msg) {
20
- const line = `[${new Date().toISOString()}] ${msg}\n`;
21
- fs.appendFileSync(DEBUG_LOG, line);
22
- console.error(`[Zibby MCP] ${msg}`);
23
- }
24
-
25
- // Append to debug log (don't overwrite - multiple MCP instances)
26
- fs.appendFileSync(DEBUG_LOG, `\n=== Zibby MCP Started ${new Date().toISOString()} PID:${process.pid} ===\n`);
27
-
28
- // ========== Event Recording ==========
29
- let nodeSessionPath = null;
30
- let events = [];
31
- let videoStartTime = null;
32
- let lastSessionKey = null; // Track when session changes
33
-
34
- function getSessionInfoPath() {
35
- const searchPaths = [
36
- process.env.ZIBBY_SESSION_INFO,
37
- path.join(process.cwd(), '.zibby/output/.session-info.json'),
38
- path.join(process.cwd(), 'test-results/.session-info.json'),
39
- ].filter(Boolean);
40
-
41
- for (const p of searchPaths) {
42
- if (fs.existsSync(p)) return p;
43
- }
44
- return null;
45
- }
46
-
47
- function loadSessionInfo() {
48
- try {
49
- const sessionInfoPath = getSessionInfoPath();
50
-
51
- if (sessionInfoPath) {
52
- const sessionInfo = JSON.parse(fs.readFileSync(sessionInfoPath, 'utf-8'));
53
- const nodeName = sessionInfo.currentNode || 'execute_live';
54
- const sessionKey = `${sessionInfo.sessionPath}:${nodeName}`;
55
-
56
- // Only update if session changed
57
- if (sessionKey !== lastSessionKey) {
58
- lastSessionKey = sessionKey;
59
-
60
- // Reset for new session/node
61
- if (sessionInfo.sessionPath) {
62
- nodeSessionPath = path.join(sessionInfo.sessionPath, nodeName);
63
- fs.mkdirSync(nodeSessionPath, { recursive: true });
64
-
65
- // Reset events for new session
66
- events = [];
67
- videoStartTime = null;
68
-
69
- debug(`📁 NEW session/node: ${nodeSessionPath}`);
70
- }
71
- }
72
- }
73
- } catch (err) {
74
- debug(`❌ Session load error: ${err.message}`);
75
- }
76
- }
77
-
78
- function formatTime(ms) {
79
- const totalSeconds = Math.floor(ms / 1000);
80
- const hours = Math.floor(totalSeconds / 3600);
81
- const minutes = Math.floor((totalSeconds % 3600) / 60);
82
- const seconds = totalSeconds % 60;
83
- const milliseconds = ms % 1000;
84
- return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}`;
85
- }
86
-
87
- function recordEvent(type, data) {
88
- // Re-check session info on each event (session may have changed)
89
- loadSessionInfo();
90
-
91
- if (!nodeSessionPath) return;
92
-
93
- const timestamp = Date.now();
94
-
95
- // Record events with raw timestamps first (will recalculate on close)
96
- if (!videoStartTime) {
97
- videoStartTime = timestamp;
98
- }
99
-
100
- const videoOffsetMs = timestamp - videoStartTime;
101
-
102
- const event = {
103
- id: events.length,
104
- type,
105
- timestamp: new Date(timestamp).toISOString(),
106
- videoOffsetMs,
107
- videoOffsetFormatted: formatTime(videoOffsetMs),
108
- data,
109
- _recordedBy: 'zibby-wrapper-v2' // Marker to verify our wrapper created this
110
- };
111
-
112
- events.push(event);
113
- debug(`📝 Event #${event.id}: ${type}${data.stableId ? ` [${data.stableId}]` : ''}`);
114
-
115
- // If this is a close event, recalculate all timestamps using video duration
116
- if (type === 'close') {
117
- recalculateTimestampsFromVideo();
118
- } else {
119
- saveEvents();
120
- }
121
-
122
- return event.id;
123
- }
124
-
125
- function updateEventStableId(eventId, stableId) {
126
- if (!nodeSessionPath || eventId < 0 || eventId >= events.length) return;
127
-
128
- const event = events[eventId];
129
- if (event && !event.data.stableId && stableId) {
130
- event.data.stableId = stableId;
131
- debug(`🔗 Event #${eventId} stableId: ${stableId}`);
132
- saveEvents();
133
- }
134
- }
135
-
136
- /**
137
- * Parse WebM file to extract duration (fallback when ffprobe not available)
138
- */
139
- function parseWebMDuration(buffer) {
140
- const SEGMENT_ID = 0x18538067;
141
- const INFO_ID = 0x1549A966;
142
- const DURATION_ID = 0x4489;
143
- const TIMECODE_SCALE_ID = 0x2AD7B1;
144
-
145
- function readVINT(buffer, pos, keepMarker) {
146
- if (pos >= buffer.length) return null;
147
- const first = buffer[pos];
148
- let length = 1;
149
- let mask = 0x80;
150
- while (length <= 8 && !(first & mask)) {
151
- length++;
152
- mask >>= 1;
153
- }
154
- if (length > 8 || pos + length > buffer.length) return null;
155
- let value = keepMarker ? first : (first & (mask - 1));
156
- for (let i = 1; i < length; i++) {
157
- value = (value << 8) | buffer[pos + i];
158
- }
159
- return { value, length };
160
- }
161
-
162
- function readEBMLElement(buffer, pos) {
163
- if (pos >= buffer.length) return null;
164
- const idResult = readVINT(buffer, pos, true);
165
- if (!idResult) return null;
166
- pos += idResult.length;
167
- const sizeResult = readVINT(buffer, pos, false);
168
- if (!sizeResult) return null;
169
- pos += sizeResult.length;
170
- return {
171
- id: idResult.value,
172
- dataStart: pos,
173
- dataSize: sizeResult.value,
174
- dataEnd: pos + sizeResult.value
175
- };
176
- }
177
-
178
- function readUInt(buffer, pos, size) {
179
- let value = 0;
180
- for (let i = 0; i < size; i++) {
181
- value = (value << 8) | buffer[pos + i];
182
- }
183
- return value;
184
- }
185
-
186
- function readFloat(buffer, pos, size) {
187
- if (size === 4) return buffer.readFloatBE(pos);
188
- if (size === 8) return buffer.readDoubleBE(pos);
189
- return null;
190
- }
191
-
192
- let pos = 0;
193
- let timecodeScale = 1000000;
194
- let duration = null;
195
-
196
- const ebmlHeader = readEBMLElement(buffer, pos);
197
- if (!ebmlHeader) return null;
198
- pos = ebmlHeader.dataEnd;
199
-
200
- const segment = readEBMLElement(buffer, pos);
201
- if (!segment || segment.id !== SEGMENT_ID) return null;
202
-
203
- pos = segment.dataStart;
204
- const segmentEnd = Math.min(segment.dataEnd, buffer.length);
205
-
206
- while (pos < segmentEnd) {
207
- const element = readEBMLElement(buffer, pos);
208
- if (!element) break;
209
-
210
- if (element.id === INFO_ID) {
211
- let infoPos = element.dataStart;
212
- while (infoPos < element.dataEnd) {
213
- const infoElement = readEBMLElement(buffer, infoPos);
214
- if (!infoElement) break;
215
- if (infoElement.id === TIMECODE_SCALE_ID) {
216
- timecodeScale = readUInt(buffer, infoElement.dataStart, infoElement.dataSize);
217
- } else if (infoElement.id === DURATION_ID) {
218
- duration = readFloat(buffer, infoElement.dataStart, infoElement.dataSize);
219
- }
220
- infoPos = infoElement.dataEnd;
221
- }
222
- break;
223
- }
224
- pos = element.dataEnd;
225
- }
226
-
227
- if (duration !== null) {
228
- return (duration * timecodeScale) / 1000000;
229
- }
230
- return null;
231
- }
232
-
233
- /**
234
- * Get video duration in milliseconds using ffprobe or WebM parser fallback
235
- */
236
- function getVideoDurationMs(videoPath) {
237
- // Try ffprobe first (most accurate)
238
- try {
239
- const result = execFileSync('ffprobe', [
240
- '-v', 'error', '-show_entries', 'format=duration',
241
- '-of', 'default=noprint_wrappers=1:nokey=1', videoPath
242
- ], { encoding: 'utf-8', timeout: 5000 });
243
- const durationSec = parseFloat(result.trim());
244
- if (!isNaN(durationSec)) {
245
- const durationMs = Math.round(durationSec * 1000);
246
- debug(`📏 Video duration (ffprobe): ${durationMs}ms`);
247
- return durationMs;
248
- }
249
- } catch (_e) {
250
- debug(`⚠️ ffprobe not available, using WebM parser fallback`);
251
- }
252
-
253
- // Fallback: Parse WebM file directly
254
- try {
255
- const buffer = fs.readFileSync(videoPath);
256
- const duration = parseWebMDuration(buffer);
257
- if (duration) {
258
- const durationMs = Math.round(duration);
259
- debug(`📏 Video duration (WebM parser): ${durationMs}ms`);
260
- return durationMs;
261
- }
262
- } catch (e) {
263
- debug(`⚠️ WebM parsing failed: ${e.message}`);
264
- }
265
-
266
- return null;
267
- }
268
-
269
- /**
270
- * Recalculate all event timestamps after browser close using actual video duration
271
- * Formula: videoStartMs = closeEventTimestamp - videoDurationMs
272
- */
273
- function recalculateTimestampsFromVideo() {
274
- if (!nodeSessionPath || events.length === 0) return;
275
-
276
- // Find video file in current node directory (Playwright creates page-*.webm files)
277
- let videoPath = null;
278
- let attempts = 0;
279
- const maxAttempts = 40; // 40 attempts * 500ms = 20 seconds max
280
- let lastSize = 0;
281
- let stableCount = 0;
282
-
283
- while (!videoPath && attempts < maxAttempts) {
284
- attempts++;
285
-
286
- // Look for either recording.webm or page-*.webm files
287
- let candidatePath = null;
288
- if (fs.existsSync(path.join(nodeSessionPath, 'recording.webm'))) {
289
- candidatePath = path.join(nodeSessionPath, 'recording.webm');
290
- if (attempts === 1) debug(`Found recording.webm`);
291
- } else {
292
- // Look for Playwright's page-*.webm pattern
293
- try {
294
- const files = fs.readdirSync(nodeSessionPath);
295
- if (attempts === 1) debug(`Directory contains ${files.length} files: ${files.join(', ')}`);
296
- const webmFiles = files.filter(f => f.endsWith('.webm'));
297
- if (attempts === 1 && webmFiles.length > 0) debug(`Found ${webmFiles.length} .webm file(s): ${webmFiles.join(', ')}`);
298
- if (webmFiles.length > 0) {
299
- candidatePath = path.join(nodeSessionPath, webmFiles[0]);
300
- }
301
- } catch (_e) {
302
- // Directory might not be ready
303
- if (attempts === 1) debug(`⚠️ Error reading directory: ${_e.message}`);
304
- }
305
- }
306
-
307
- // If we found a file, check if it's stable (not still being written)
308
- if (candidatePath && fs.existsSync(candidatePath)) {
309
- try {
310
- const stats = fs.statSync(candidatePath);
311
- const currentSize = stats.size;
312
-
313
- if (currentSize > 0 && currentSize === lastSize) {
314
- stableCount++;
315
- if (stableCount >= 2) { // File size unchanged for 2 checks (1 second)
316
- videoPath = candidatePath;
317
- debug(`✅ Video file stable after ${attempts} attempts: ${path.basename(videoPath)} (${currentSize} bytes)`);
318
- break;
319
- }
320
- } else {
321
- stableCount = 0;
322
- lastSize = currentSize;
323
- }
324
- } catch (_e) {
325
- debug(`⚠️ Error checking file: ${_e.message}`);
326
- }
327
- }
328
-
329
- if (!videoPath) {
330
- if (attempts % 5 === 0) { // Log every 2.5 seconds
331
- debug(`⏳ Waiting for stable video file... (attempt ${attempts}/${maxAttempts}, size: ${lastSize})`);
332
- }
333
- // Blocking sleep
334
- const start = Date.now();
335
- while (Date.now() - start < 500) { /* 500ms sleep */ }
336
- }
337
- }
338
-
339
- if (!videoPath) {
340
- debug(`⚠️ Video file not found/stable after ${attempts} attempts in: ${nodeSessionPath}`);
341
- saveEvents();
342
- return;
343
- }
344
-
345
-
346
- // Get video duration
347
- const videoDurationMs = getVideoDurationMs(videoPath);
348
- if (!videoDurationMs) {
349
- debug(`⚠️ Could not determine video duration, using fallback`);
350
- saveEvents();
351
- return;
352
- }
353
-
354
- // Find close event
355
- const closeEvent = events.find(e => e.type === 'close');
356
- if (!closeEvent) {
357
- debug(`⚠️ No close event found`);
358
- saveEvents();
359
- return;
360
- }
361
-
362
- // Calculate accurate video start time
363
- const closeTimestamp = new Date(closeEvent.timestamp).getTime();
364
- const calculatedVideoStartMs = closeTimestamp - videoDurationMs;
365
-
366
- debug(`📊 Calculated video start: ${new Date(calculatedVideoStartMs).toISOString()}`);
367
- debug(` Close timestamp: ${closeTimestamp}ms`);
368
- debug(` Video duration: ${videoDurationMs}ms`);
369
-
370
- // Recalculate all event timestamps
371
- events.forEach(event => {
372
- const eventTimestamp = new Date(event.timestamp).getTime();
373
- event.videoOffsetMs = eventTimestamp - calculatedVideoStartMs;
374
- event.videoOffsetFormatted = formatTime(event.videoOffsetMs);
375
- });
376
-
377
- debug(`✅ Recalculated ${events.length} events using accurate video start time`);
378
- saveEvents();
379
- }
380
-
381
- function saveEvents() {
382
- if (!nodeSessionPath) return;
383
- try {
384
- const eventsPath = path.join(nodeSessionPath, 'events.json');
385
- fs.writeFileSync(eventsPath, JSON.stringify(events, null, 2));
386
- } catch (_err) {
387
- // Silent fail
388
- }
389
- }
390
-
391
- // ========== Main ==========
392
- debug(`CWD: ${process.cwd()}`);
393
- loadSessionInfo();
394
-
395
- // Build arguments - add our init script
396
- const args = process.argv.slice(2);
397
-
398
- // Always inject our stable ID script
399
- if (fs.existsSync(STABLE_ID_SCRIPT)) {
400
- args.unshift('--init-script', STABLE_ID_SCRIPT);
401
- debug(`✅ Stable ID injection enabled: ${STABLE_ID_SCRIPT}`);
402
- } else {
403
- debug(`⚠️ Stable ID script not found: ${STABLE_ID_SCRIPT}`);
404
- }
405
-
406
- // Set output directory to node session folder so videos land in the right place
407
- if (nodeSessionPath) {
408
- const existingIdx = args.findIndex(a => a.startsWith('--output-dir'));
409
- if (existingIdx !== -1) {
410
- args[existingIdx] = `--output-dir=${nodeSessionPath}`;
411
- } else {
412
- args.push('--output-dir', nodeSessionPath);
413
- }
414
- debug(`📹 Video output: ${nodeSessionPath}`);
415
- }
416
-
417
- // Find the official Playwright MCP CLI
418
- const playwrightMcpPath = path.dirname(require.resolve('@playwright/mcp'));
419
- const playwrightMcpCli = path.join(playwrightMcpPath, 'cli.js');
420
-
421
- // Spawn the official MCP
422
- const mcpProcess = spawn(process.execPath, [playwrightMcpCli, ...args], {
423
- stdio: ['pipe', 'pipe', 'inherit']
424
- });
425
-
426
- // Track requests and pending stable ID queries
427
- const pendingRequests = new Map();
428
- let stableIdQueryId = 900000;
429
- const pendingStableIdQueries = new Map();
430
-
431
- // Track if browser is still active
432
- let browserActive = false;
433
-
434
- // ========== Stdin Processing ==========
435
- let stdinBuffer = '';
436
-
437
- process.stdin.on('data', (data) => {
438
- stdinBuffer += data.toString();
439
-
440
- // Process complete lines
441
- const lines = stdinBuffer.split('\n');
442
- stdinBuffer = lines.pop() || '';
443
-
444
- for (const line of lines) {
445
- if (!line.trim()) continue;
446
-
447
- try {
448
- const msg = JSON.parse(line);
449
-
450
- // Debug: log incoming messages
451
- if (msg.method) {
452
- debug(`📨 STDIN: ${msg.method} ${msg.params?.name || ''}`);
453
- }
454
-
455
- // Intercept tool calls
456
- if (msg.method === 'tools/call' && msg.params) {
457
- const toolName = msg.params.name;
458
- const toolArgs = msg.params.arguments || {};
459
-
460
- // Track pending request
461
- pendingRequests.set(msg.id, { toolName, toolArgs });
462
-
463
- // Record event and get ID
464
- let eventId = -1;
465
- if (toolName === 'browser_navigate') {
466
- browserActive = true;
467
- eventId = recordEvent('navigate', { url: toolArgs.url });
468
- } else if (toolName === 'browser_click') {
469
- eventId = recordEvent('click', { element: toolArgs.element, ref: toolArgs.ref });
470
- } else if (toolName === 'browser_type') {
471
- eventId = recordEvent('type', { element: toolArgs.element, ref: toolArgs.ref, text: toolArgs.text });
472
- } else if (toolName === 'browser_select_option') {
473
- eventId = recordEvent('select', { element: toolArgs.element, ref: toolArgs.ref, values: toolArgs.values });
474
- } else if (toolName === 'browser_fill_form') {
475
- eventId = recordEvent('fill_form', { fields: toolArgs.fields });
476
- } else if (toolName === 'browser_hover') {
477
- eventId = recordEvent('hover', { element: toolArgs.element, ref: toolArgs.ref });
478
- } else if (toolName === 'browser_close') {
479
- eventId = recordEvent('close', {});
480
- browserActive = false;
481
- } else if (toolName === 'browser_snapshot') {
482
- eventId = recordEvent('snapshot', {});
483
- }
484
-
485
- // Store event ID for stable ID update
486
- if (eventId >= 0) {
487
- pendingRequests.get(msg.id).eventId = eventId;
488
- }
489
- }
490
- } catch (_e) {
491
- // Not JSON - pass through
492
- }
493
-
494
- // Forward to MCP
495
- mcpProcess.stdin.write(line + '\n');
496
- }
497
- });
498
-
499
- process.stdin.on('end', () => {
500
- if (stdinBuffer.trim()) {
501
- mcpProcess.stdin.write(stdinBuffer + '\n');
502
- }
503
- mcpProcess.stdin.end();
504
- });
505
-
506
- // ========== Stdout Processing ==========
507
- let stdoutBuffer = '';
508
-
509
- mcpProcess.stdout.on('data', (data) => {
510
- stdoutBuffer += data.toString();
511
-
512
- // Process complete lines
513
- const lines = stdoutBuffer.split('\n');
514
- stdoutBuffer = lines.pop() || '';
515
-
516
- for (const line of lines) {
517
- if (!line.trim()) continue;
518
-
519
- let shouldForward = true;
520
-
521
- try {
522
- const msg = JSON.parse(line);
523
-
524
- // Check if this is a response to a tracked action
525
- if (msg.id && pendingRequests.has(msg.id)) {
526
- const request = pendingRequests.get(msg.id);
527
- pendingRequests.delete(msg.id);
528
-
529
- // For successful interaction actions, query stable ID
530
- if (!msg.error && browserActive && request.eventId >= 0) {
531
- const needsStableId = ['browser_click', 'browser_type', 'browser_select_option',
532
- 'browser_fill_form', 'browser_hover'].includes(request.toolName);
533
-
534
- if (needsStableId) {
535
- // Send stable ID query
536
- const queryId = stableIdQueryId++;
537
- const query = {
538
- jsonrpc: '2.0',
539
- id: queryId,
540
- method: 'tools/call',
541
- params: {
542
- name: 'browser_evaluate',
543
- arguments: {
544
- function: '() => { const match = document.cookie.match(/(?:^|; )__zibbyLastStableId=([^;]*)/); const id = match ? decodeURIComponent(match[1]) : (window.__zibbyLastStableId || null); return id; }'
545
- }
546
- }
547
- };
548
-
549
- debug(`🔍 Querying stable ID for event #${request.eventId}`);
550
- pendingStableIdQueries.set(queryId, { eventId: request.eventId });
551
- mcpProcess.stdin.write(JSON.stringify(query) + '\n');
552
- }
553
- }
554
- }
555
-
556
- // Check if this is our stable ID query response
557
- if (msg.id && pendingStableIdQueries.has(msg.id)) {
558
- const queryInfo = pendingStableIdQueries.get(msg.id);
559
- pendingStableIdQueries.delete(msg.id);
560
-
561
- // Extract stable ID from response
562
- debug(`📥 Stable ID response for event #${queryInfo.eventId}: ${JSON.stringify(msg.result || msg.error || 'no result').slice(0, 200)}`);
563
-
564
- if (msg.result?.content?.[0]?.text) {
565
- const text = msg.result.content[0].text;
566
- // Look for zibby ID in the response
567
- const match = text.match(/zibby-[a-zA-Z0-9-]+/);
568
- if (match) {
569
- debug(`✅ Found stable ID: ${match[0]}`);
570
- updateEventStableId(queryInfo.eventId, match[0]);
571
- } else {
572
- debug(`⚠️ No stable ID found in: ${text.slice(0, 100)}`);
573
- }
574
- } else if (msg.error) {
575
- debug(`❌ Stable ID query error: ${msg.error?.message || JSON.stringify(msg.error)}`);
576
- }
577
-
578
- // Don't forward our internal query responses
579
- shouldForward = false;
580
- }
581
- } catch (_e) {
582
- // Not JSON - forward as-is
583
- }
584
-
585
- if (shouldForward) {
586
- process.stdout.write(line + '\n');
587
- }
588
- }
589
- });
590
-
591
- // ========== Process Handling ==========
592
- mcpProcess.on('close', (code) => {
593
- // Write any remaining buffer
594
- if (stdoutBuffer.trim()) {
595
- process.stdout.write(stdoutBuffer);
596
- }
597
- process.exit(code || 0);
598
- });
599
-
600
- process.on('SIGINT', () => {
601
- mcpProcess.kill('SIGINT');
602
- });
603
-
604
- process.on('SIGTERM', () => {
605
- mcpProcess.kill('SIGTERM');
606
- });