@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.
- package/dist/bin/mcp-browser-zibby.js +14 -0
- package/dist/lib/browser/config.js +1 -0
- package/dist/lib/browser/context.js +1 -0
- package/dist/lib/browser/tab.js +1 -0
- package/dist/lib/browser/tools/common.js +1 -0
- package/dist/lib/browser/tools/keyboard.js +1 -0
- package/dist/lib/browser/tools/navigate.js +1 -0
- package/dist/lib/browser/tools/snapshot.js +1 -0
- package/dist/lib/sdk/bundle.js +1 -0
- package/dist/stable-id-inject.js +1 -0
- package/package.json +10 -8
- package/bin/mcp-browser-zibby.js +0 -606
- package/lib/browser/config.js +0 -369
- package/lib/browser/context.js +0 -274
- package/lib/browser/tab.js +0 -301
- package/lib/browser/tools/common.js +0 -67
- package/lib/browser/tools/keyboard.js +0 -88
- package/lib/browser/tools/navigate.js +0 -66
- package/lib/browser/tools/snapshot.js +0 -197
- package/lib/sdk/bundle.js +0 -84
- package/src/index.mjs +0 -594
- package/src/stable-id-inject.js +0 -347
package/bin/mcp-browser-zibby.js
DELETED
|
@@ -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
|
-
});
|