@wordbricks/playwright-mcp 0.1.26 → 0.1.27

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.
@@ -288,6 +288,20 @@ class PersistentContextFactory {
288
288
  const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
289
289
  this._userDataDirs.add(userDataDir);
290
290
  testDebug("lock user data dir", userDataDir);
291
+ // Try to connect to an existing browser via CDP first
292
+ // This enables concurrent chats to share the same browser instance
293
+ const cdpResult = await this._tryConnectViaCDP(userDataDir);
294
+ if (cdpResult) {
295
+ testDebug("connected to existing browser via CDP");
296
+ const { browserContext, initialPage } = cdpResult;
297
+ await applyInitScript(browserContext, this.config);
298
+ await hidePlaywrightMarkers(browserContext);
299
+ // Increment ref count for this CDP connection
300
+ await this._incrementRefCount(userDataDir);
301
+ // Close only this chat's page, not the entire shared context
302
+ const close = () => this._closeCdpContext(initialPage, userDataDir);
303
+ return { browserContext, close, initialPage };
304
+ }
291
305
  // Disable password manager in Chrome preferences before launch
292
306
  await disablePasswordManagerInPrefs(userDataDir);
293
307
  const browserType = playwright[this.config.browser.browserName];
@@ -301,19 +315,44 @@ class PersistentContextFactory {
301
315
  handleSIGINT: false,
302
316
  handleSIGTERM: false,
303
317
  });
318
+ // Write CDP port to file for other instances to connect
319
+ const cdpPort = this.config.browser.launchOptions?.cdpPort;
320
+ if (cdpPort) {
321
+ const cdpEndpointFile = path.join(userDataDir, "PlaywrightCdpPort");
322
+ await fs.promises.writeFile(cdpEndpointFile, String(cdpPort), "utf8");
323
+ }
324
+ // Get the initial page (persistent context creates one automatically)
325
+ const initialPage = browserContext.pages()[0];
304
326
  await applyInitScript(browserContext, this.config);
305
327
  await hidePlaywrightMarkers(browserContext);
328
+ // Increment ref count for this persistent context
329
+ await this._incrementRefCount(userDataDir);
306
330
  // Start auto-close timer
307
331
  this._startAutoCloseTimer(browserContext);
308
- const close = () => this._closeBrowserContext(browserContext, userDataDir);
309
- return { browserContext, close };
332
+ // Only close the initial page, not the entire context
333
+ // This allows other chats to continue using the shared browser
334
+ const close = () => this._closePersistentPage(initialPage, browserContext, userDataDir);
335
+ return { browserContext, close, initialPage };
310
336
  }
311
337
  catch (error) {
312
338
  if (error.message.includes("Executable doesn't exist"))
313
339
  throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
314
340
  if (error.message.includes("ProcessSingleton") ||
315
341
  error.message.includes("Invalid URL")) {
316
- // User data directory is already in use, try again.
342
+ // User data directory is already in use, try CDP connection
343
+ testDebug("browser in use, trying CDP connection...");
344
+ const cdpRetry = await this._tryConnectViaCDP(userDataDir);
345
+ if (cdpRetry) {
346
+ testDebug("connected to existing browser via CDP (retry)");
347
+ const { browserContext, initialPage } = cdpRetry;
348
+ await applyInitScript(browserContext, this.config);
349
+ await hidePlaywrightMarkers(browserContext);
350
+ // Increment ref count for this CDP connection
351
+ await this._incrementRefCount(userDataDir);
352
+ // Close only this chat's page, not the entire shared context
353
+ const close = () => this._closeCdpContext(initialPage, userDataDir);
354
+ return { browserContext, close, initialPage };
355
+ }
317
356
  await new Promise((resolve) => setTimeout(resolve, 1000));
318
357
  continue;
319
358
  }
@@ -322,6 +361,89 @@ class PersistentContextFactory {
322
361
  }
323
362
  throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
324
363
  }
364
+ /**
365
+ * Try to connect to an existing browser via CDP.
366
+ * First tries PlaywrightCdpPort (written by our code), then falls back to DevToolsActivePort.
367
+ */
368
+ async _tryConnectViaCDP(userDataDir) {
369
+ const playwrightPortFile = path.join(userDataDir, "PlaywrightCdpPort");
370
+ const devToolsPortFile = path.join(userDataDir, "DevToolsActivePort");
371
+ try {
372
+ let port = null;
373
+ // Try PlaywrightCdpPort first (written by our code)
374
+ try {
375
+ const content = await fs.promises.readFile(playwrightPortFile, "utf8");
376
+ port = Number.parseInt(content.trim(), 10);
377
+ }
378
+ catch {
379
+ // Fall back to DevToolsActivePort (written by Chrome)
380
+ const content = await fs.promises.readFile(devToolsPortFile, "utf8");
381
+ const lines = content.trim().split("\n");
382
+ port = Number.parseInt(lines[0], 10);
383
+ }
384
+ if (!port || Number.isNaN(port)) {
385
+ testDebug("invalid port in CDP port file");
386
+ return null;
387
+ }
388
+ testDebug(`attempting CDP connection on port ${port}`);
389
+ const cdpEndpoint = `http://127.0.0.1:${port}`;
390
+ // Try to connect with a short timeout
391
+ const browser = await Promise.race([
392
+ playwright.chromium.connectOverCDP(cdpEndpoint),
393
+ new Promise((_, reject) => setTimeout(() => reject(new Error("CDP connection timeout")), 3000)),
394
+ ]);
395
+ // Reuse existing context to share login sessions
396
+ const existingContexts = browser.contexts();
397
+ const browserContext = existingContexts.length > 0
398
+ ? existingContexts[0]
399
+ : await browser.newContext({
400
+ ...this.config.browser.contextOptions,
401
+ bypassCSP: true,
402
+ });
403
+ // Get window size and position from launch args for the popup
404
+ const args = this.config.browser.launchOptions?.args || [];
405
+ const sizeArg = args.find((arg) => arg.startsWith("--window-size="));
406
+ const posArg = args.find((arg) => arg.startsWith("--window-position="));
407
+ const appArg = args.find((arg) => arg.startsWith("--app="));
408
+ const [width, height] = sizeArg
409
+ ? sizeArg.replace("--window-size=", "").split(",").map(Number)
410
+ : [600, 1070];
411
+ const [left, top] = posArg
412
+ ? posArg.replace("--window-position=", "").split(",").map(Number)
413
+ : [1200, 40];
414
+ const appUrl = appArg ? appArg.replace("--app=", "") : "about:blank";
415
+ // Use window.open with popup features to create an app-like window (no address bar)
416
+ const existingPage = browserContext.pages()[0];
417
+ let newPage;
418
+ if (existingPage) {
419
+ const popupFeatures = `popup=true,width=${width},height=${height},left=${left},top=${top}`;
420
+ // Create popup and wait for the new page
421
+ const [popupPage] = await Promise.all([
422
+ browserContext.waitForEvent("page", { timeout: 5000 }),
423
+ existingPage.evaluate(([url, features]) => window.open(url, "_blank", features), [appUrl, popupFeatures]),
424
+ ]);
425
+ newPage = popupPage;
426
+ }
427
+ return { browser, browserContext, initialPage: newPage };
428
+ }
429
+ catch (error) {
430
+ testDebug(`CDP connection failed: ${error}`);
431
+ return null;
432
+ }
433
+ }
434
+ async _closeCdpContext(page, userDataDir) {
435
+ testDebug("close CDP page");
436
+ // Only close the specific page belonging to this chat, not the entire context
437
+ // Other chats may still be using the shared browserContext
438
+ if (page && !page.isClosed()) {
439
+ await page.close().catch(logUnhandledError);
440
+ }
441
+ // Decrement reference count
442
+ const remainingRefs = await this._decrementRefCount(userDataDir);
443
+ testDebug("remaining refs after CDP close:", remainingRefs);
444
+ // Note: We don't close the browser from CDP connections - the original
445
+ // persistent context owner (Chat A) or auto-close timer will handle that
446
+ }
325
447
  _startAutoCloseTimer(browserContext) {
326
448
  this._clearAutoCloseTimer();
327
449
  testDebug(`schedule auto-close in ${TIMEOUT_STR} (persistent)`);
@@ -351,6 +473,103 @@ class PersistentContextFactory {
351
473
  this._userDataDirs.delete(userDataDir);
352
474
  testDebug("close browser context complete (persistent)");
353
475
  }
476
+ async _closePersistentPage(page, browserContext, userDataDir) {
477
+ testDebug("close persistent page");
478
+ // Decrement reference count first to check if others are using the browser
479
+ const remainingRefs = await this._decrementRefCount(userDataDir);
480
+ testDebug("remaining refs after close:", remainingRefs);
481
+ if (remainingRefs <= 0) {
482
+ // No other processes using the browser, safe to close everything
483
+ testDebug("no refs left, closing browser context");
484
+ this._clearAutoCloseTimer();
485
+ await browserContext.close().catch(() => { });
486
+ this._userDataDirs.delete(userDataDir);
487
+ }
488
+ else {
489
+ // Other processes are still using the browser
490
+ // Don't close the page - just navigate to blank to "release" it
491
+ // Closing the page would close popup windows opened from it (window.open behavior)
492
+ testDebug("other refs exist, navigating to blank instead of closing page");
493
+ if (page && !page.isClosed()) {
494
+ await page.goto("about:blank").catch(() => { });
495
+ }
496
+ }
497
+ }
498
+ /**
499
+ * Increment the reference count for a shared browser userDataDir.
500
+ * Each MCP process using the browser increments this count.
501
+ */
502
+ async _incrementRefCount(userDataDir) {
503
+ const refCountFile = path.join(userDataDir, "PlaywrightRefCount");
504
+ const lockFile = path.join(userDataDir, "PlaywrightRefCount.lock");
505
+ // Simple file-based locking
506
+ for (let i = 0; i < 50; i++) {
507
+ try {
508
+ await fs.promises.writeFile(lockFile, String(process.pid), {
509
+ flag: "wx",
510
+ });
511
+ break;
512
+ }
513
+ catch {
514
+ await new Promise((resolve) => setTimeout(resolve, 100));
515
+ }
516
+ }
517
+ try {
518
+ let count = 0;
519
+ try {
520
+ const content = await fs.promises.readFile(refCountFile, "utf8");
521
+ count = Number.parseInt(content, 10) || 0;
522
+ }
523
+ catch {
524
+ // File doesn't exist, start at 0
525
+ }
526
+ count++;
527
+ await fs.promises.writeFile(refCountFile, String(count), "utf8");
528
+ testDebug("incremented ref count to:", count);
529
+ return count;
530
+ }
531
+ finally {
532
+ await fs.promises.unlink(lockFile).catch(() => { });
533
+ }
534
+ }
535
+ /**
536
+ * Decrement the reference count for a shared browser userDataDir.
537
+ * Returns the new count after decrementing.
538
+ */
539
+ async _decrementRefCount(userDataDir) {
540
+ const refCountFile = path.join(userDataDir, "PlaywrightRefCount");
541
+ const lockFile = path.join(userDataDir, "PlaywrightRefCount.lock");
542
+ // Simple file-based locking
543
+ for (let i = 0; i < 50; i++) {
544
+ try {
545
+ await fs.promises.writeFile(lockFile, String(process.pid), {
546
+ flag: "wx",
547
+ });
548
+ break;
549
+ }
550
+ catch {
551
+ await new Promise((resolve) => setTimeout(resolve, 100));
552
+ }
553
+ }
554
+ try {
555
+ let count = 0;
556
+ try {
557
+ const content = await fs.promises.readFile(refCountFile, "utf8");
558
+ count = Number.parseInt(content, 10) || 0;
559
+ }
560
+ catch {
561
+ // File doesn't exist
562
+ return 0;
563
+ }
564
+ count = Math.max(0, count - 1);
565
+ await fs.promises.writeFile(refCountFile, String(count), "utf8");
566
+ testDebug("decremented ref count to:", count);
567
+ return count;
568
+ }
569
+ finally {
570
+ await fs.promises.unlink(lockFile).catch(() => { });
571
+ }
572
+ }
354
573
  async _createUserDataDir(rootPath) {
355
574
  const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
356
575
  const browserToken = this.config.browser.launchOptions?.channel ??
@@ -363,12 +582,10 @@ class PersistentContextFactory {
363
582
  }
364
583
  }
365
584
  async function injectCdpPort(browserConfig) {
366
- const isBunRuntime = "bun" in process.versions;
367
- const isWindows = process.platform === "win32";
368
- // On Windows, always use CDP port because Bun has issues with --remote-debugging-pipe
369
- // On other platforms, Bun handles pipes correctly
370
- if (!isWindows && isBunRuntime)
371
- return;
585
+ // Always use CDP port for Chromium to enable concurrent browser access.
586
+ // When using a shared user data directory, subsequent instances need to
587
+ // connect via CDP to the existing browser instead of launching a new one.
588
+ // Chrome writes the assigned port to DevToolsActivePort file.
372
589
  if (browserConfig.browserName !== "chromium")
373
590
  return;
374
591
  const launchOptions = browserConfig.launchOptions || {};
package/lib/config.js CHANGED
@@ -93,8 +93,10 @@ export function configFromCLIOptions(cliOptions) {
93
93
  "--hide-crash-restore-bubble",
94
94
  // First run & default browser
95
95
  "--no-first-run", "--no-default-browser-check",
96
- // Disable various Chrome features (Translate, PasswordManager, Autofill, Sync)
97
- "--disable-features=Translate,PasswordManager,PasswordManagerEnabled,PasswordManagerOnboarding,AutofillServerCommunication,CredentialManagerOnboarding", "--disable-sync",
96
+ // Disable Cast/Media Router and local network discovery
97
+ "--media-router=0", "--disable-cast", "--disable-cast-streaming-hw-encoding",
98
+ // Disable various Chrome features (Translate, PasswordManager, Autofill, Sync, MediaRouter, Cast)
99
+ "--disable-features=Translate,PasswordManager,PasswordManagerEnabled,PasswordManagerOnboarding,AutofillServerCommunication,CredentialManagerOnboarding,MediaRouter,GlobalMediaControls,CastMediaRouteProvider,DialMediaRouteProvider,CastAllowAllIPs,EnableCastDiscovery,LocalNetworkAccessChecks", "--disable-sync",
98
100
  // Disable password manager via experimental options
99
101
  "--enable-features=DisablePasswordManager");
100
102
  // --app was passed, add app mode argument
package/lib/context.js CHANGED
@@ -234,13 +234,22 @@ export class Context {
234
234
  throw new Error("Another browser context is being closed.");
235
235
  // TODO: move to the browser context factory to make it based on isolation mode.
236
236
  const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
237
- const { browserContext } = result;
237
+ const { browserContext, initialPage } = result;
238
238
  await this._setupRequestInterception(browserContext);
239
239
  if (this.sessionLog)
240
240
  await InputRecorder.create(this, browserContext);
241
241
  for (const page of browserContext.pages())
242
242
  this._onPageCreated(page);
243
243
  browserContext.on("page", (page) => this._onPageCreated(page));
244
+ // If an initialPage was provided (e.g., from CDP connection), set it as the current tab
245
+ // This ensures each chat controls its own browser window, not someone else's
246
+ if (initialPage) {
247
+ const tab = this._tabs.find((t) => t.page === initialPage);
248
+ if (tab) {
249
+ this._currentTab = tab;
250
+ await initialPage.bringToFront();
251
+ }
252
+ }
244
253
  if (this.config.saveTrace) {
245
254
  await browserContext.tracing.start({
246
255
  name: "trace",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wordbricks/playwright-mcp",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Playwright Tools for MCP",
5
5
  "type": "module",
6
6
  "files": [