playwriter 0.3.0 → 0.4.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.
Files changed (59) hide show
  1. package/dist/bippy.js +5 -5
  2. package/dist/browser-config.d.ts.map +1 -1
  3. package/dist/browser-config.js +8 -2
  4. package/dist/browser-config.js.map +1 -1
  5. package/dist/browser-install.d.ts +16 -0
  6. package/dist/browser-install.d.ts.map +1 -0
  7. package/dist/browser-install.js +237 -0
  8. package/dist/browser-install.js.map +1 -0
  9. package/dist/cdp-relay.d.ts.map +1 -1
  10. package/dist/cdp-relay.js +261 -29
  11. package/dist/cdp-relay.js.map +1 -1
  12. package/dist/chrome-discovery.d.ts.map +1 -1
  13. package/dist/chrome-discovery.js +8 -0
  14. package/dist/chrome-discovery.js.map +1 -1
  15. package/dist/cli.js +578 -17
  16. package/dist/cli.js.map +1 -1
  17. package/dist/cloud-client.d.ts +56 -0
  18. package/dist/cloud-client.d.ts.map +1 -0
  19. package/dist/cloud-client.js +120 -0
  20. package/dist/cloud-client.js.map +1 -0
  21. package/dist/executor.d.ts +46 -3
  22. package/dist/executor.d.ts.map +1 -1
  23. package/dist/executor.js +249 -26
  24. package/dist/executor.js.map +1 -1
  25. package/dist/extension/background.js +106 -23
  26. package/dist/extension/manifest.json +1 -1
  27. package/dist/playwright-import.d.ts +19 -0
  28. package/dist/playwright-import.d.ts.map +1 -0
  29. package/dist/playwright-import.js +39 -0
  30. package/dist/playwright-import.js.map +1 -0
  31. package/dist/prompt.md +32 -0
  32. package/dist/readability.js +1 -1
  33. package/dist/relay-session.test.js +1 -1
  34. package/dist/relay-session.test.js.map +1 -1
  35. package/dist/relay-state.d.ts +1 -0
  36. package/dist/relay-state.d.ts.map +1 -1
  37. package/dist/relay-state.js +18 -0
  38. package/dist/relay-state.js.map +1 -1
  39. package/dist/relay-state.test.js +22 -0
  40. package/dist/relay-state.test.js.map +1 -1
  41. package/dist/selector-generator.js +1 -1
  42. package/dist/utils.d.ts +2 -2
  43. package/dist/utils.d.ts.map +1 -1
  44. package/dist/utils.js +4 -4
  45. package/dist/utils.js.map +1 -1
  46. package/package.json +3 -1
  47. package/src/browser-config.ts +11 -2
  48. package/src/browser-install.ts +283 -0
  49. package/src/cdp-relay.ts +306 -32
  50. package/src/chrome-discovery.ts +9 -0
  51. package/src/cli.ts +645 -19
  52. package/src/cloud-client.ts +172 -0
  53. package/src/executor.ts +295 -28
  54. package/src/playwright-import.ts +58 -0
  55. package/src/relay-session.test.ts +1 -1
  56. package/src/relay-state.test.ts +32 -0
  57. package/src/relay-state.ts +19 -1
  58. package/src/skill.md +154 -14
  59. package/src/utils.ts +4 -5
@@ -0,0 +1,172 @@
1
+ // HTTP client for CLI to call the website's /api/cloud/* routes.
2
+ // Auth: three methods, checked in priority order:
3
+ // 1. PLAYWRITER_API_KEY env var → sent as x-api-key header (for CI/VPS/headless)
4
+ // 2. PLAYWRITER_CLOUD_TOKEN env var → sent as Authorization: Bearer (for CI with session tokens)
5
+ // 3. ~/.playwriter/auth.json file → saved by `cloud login` device flow
6
+
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+
11
+ const DEFAULT_BASE_URL = 'https://playwriter.dev'
12
+ const AUTH_FILE = path.join(os.homedir(), '.playwriter', 'auth.json')
13
+
14
+ /** Build a playwriter.dev/live URL from the exact CDP WebSocket URL.
15
+ * Passes the full wss endpoint as ?wss= param so the client connects
16
+ * to the exact host (Browser Use can shard across cdp1, cdp2, etc.). */
17
+ export function buildLiveUrl(cdpUrl: string, baseUrl: string = DEFAULT_BASE_URL): string {
18
+ // Browser Use returns https:// CDP URLs; the live viewer needs wss://
19
+ const wssUrl = cdpUrl.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://')
20
+ const url = new URL('/live', baseUrl)
21
+ url.searchParams.set('wss', wssUrl)
22
+ return url.toString()
23
+ }
24
+
25
+ // ── Auth persistence ────────────────────────────────────────────────
26
+
27
+ export interface CloudAuth {
28
+ token: string
29
+ baseUrl: string
30
+ /** When true, token is an API key sent via x-api-key header instead of Bearer */
31
+ isApiKey?: boolean
32
+ }
33
+
34
+ export function loadCloudAuth(): CloudAuth | null {
35
+ // API key takes highest priority (simplest for CI/VPS/headless)
36
+ const apiKey = process.env.PLAYWRITER_API_KEY
37
+ if (apiKey) {
38
+ return { token: apiKey, baseUrl: process.env.PLAYWRITER_CLOUD_URL || DEFAULT_BASE_URL, isApiKey: true }
39
+ }
40
+ // Session token env var (for CI with device flow tokens)
41
+ const envToken = process.env.PLAYWRITER_CLOUD_TOKEN
42
+ if (envToken) {
43
+ return { token: envToken, baseUrl: process.env.PLAYWRITER_CLOUD_URL || DEFAULT_BASE_URL }
44
+ }
45
+ try {
46
+ const data = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'))
47
+ if (data.token) {
48
+ return { token: data.token, baseUrl: data.baseUrl || DEFAULT_BASE_URL }
49
+ }
50
+ } catch {
51
+ // No auth file
52
+ }
53
+ return null
54
+ }
55
+
56
+ export function saveCloudAuth(auth: CloudAuth): void {
57
+ const dir = path.dirname(AUTH_FILE)
58
+ fs.mkdirSync(dir, { recursive: true })
59
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), { encoding: 'utf-8', mode: 0o600 })
60
+ }
61
+
62
+ // ── Cloud session status types ───────────────────────────────────────
63
+
64
+ export interface CloudSessionStatus {
65
+ cloudSessionId: string
66
+ browserUseSessionId: string
67
+ index: number
68
+ createdAt: number
69
+ status: 'active' | 'stopped'
70
+ cdpUrl: string | null
71
+ liveUrl: string | null
72
+ timeoutAt: string
73
+ }
74
+
75
+ export interface ConnectResult {
76
+ cloudSessionId: string
77
+ cdpUrl: string | null
78
+ liveUrl: string | null
79
+ /** BU VM hard timeout (ISO string from server) */
80
+ timeoutAt?: string
81
+ }
82
+
83
+ // ── Client ──────────────────────────────────────────────────────────
84
+
85
+ export class CloudClient {
86
+ private baseUrl: string
87
+ private token: string
88
+ private isApiKey: boolean
89
+
90
+ constructor(auth: CloudAuth) {
91
+ this.baseUrl = auth.baseUrl
92
+ this.token = auth.token
93
+ this.isApiKey = auth.isApiKey ?? false
94
+ }
95
+
96
+ private async request<T>(
97
+ method: string,
98
+ path: string,
99
+ body?: Record<string, unknown>,
100
+ ): Promise<T> {
101
+ const url = new URL(path, this.baseUrl).toString()
102
+ const headers: Record<string, string> = {
103
+ 'Content-Type': 'application/json',
104
+ }
105
+ // API keys use x-api-key header; session tokens use Authorization: Bearer
106
+ if (this.isApiKey) {
107
+ headers['x-api-key'] = this.token
108
+ } else {
109
+ headers['Authorization'] = `Bearer ${this.token}`
110
+ }
111
+
112
+ const response = await fetch(url, {
113
+ method,
114
+ headers,
115
+ body: body ? JSON.stringify(body) : undefined,
116
+ })
117
+
118
+ if (response.status === 401) {
119
+ throw new Error('Cloud auth expired or invalid. Run `playwriter cloud login` or set PLAYWRITER_API_KEY.')
120
+ }
121
+
122
+ if (!response.ok) {
123
+ const text = await response.text().catch(() => '')
124
+ let detail = text
125
+ try {
126
+ const json = JSON.parse(text)
127
+ detail = json.error || json.message || text
128
+ } catch {
129
+ // use raw text
130
+ }
131
+ throw new Error(`Cloud API error: ${response.status} — ${detail}`)
132
+ }
133
+
134
+ return response.json() as Promise<T>
135
+ }
136
+
137
+ async getStatus(): Promise<{ sessions: CloudSessionStatus[] }> {
138
+ return this.request('GET', '/api/cloud/status')
139
+ }
140
+
141
+ async connect(options: {
142
+ proxyRegion?: string
143
+ customProxy?: { host: string; port: number; username?: string; password?: string }
144
+ /** Cloud browser timeout in minutes (1-240, default 60) */
145
+ timeout?: number
146
+ }): Promise<ConnectResult> {
147
+ return this.request('POST', '/api/cloud/connect', {
148
+ proxyRegion: options.proxyRegion,
149
+ customProxy: options.customProxy,
150
+ ...(options.timeout ? { timeout: options.timeout } : {}),
151
+ })
152
+ }
153
+
154
+ async disconnect(cloudSessionId: string): Promise<void> {
155
+ await this.request('POST', '/api/cloud/disconnect', { cloudSessionId })
156
+ }
157
+
158
+ /** Get a single session's status by cloudSessionId (from the status list). */
159
+ async getSessionStatus(cloudSessionId: string): Promise<CloudSessionStatus | null> {
160
+ const { sessions } = await this.getStatus()
161
+ return sessions.find((s) => {
162
+ return s.cloudSessionId === cloudSessionId
163
+ }) ?? null
164
+ }
165
+ }
166
+
167
+ /** Create a CloudClient from saved auth, or null if not logged in. */
168
+ export function getCloudClient(): CloudClient | null {
169
+ const auth = loadCloudAuth()
170
+ if (!auth) return null
171
+ return new CloudClient(auth)
172
+ }
package/src/executor.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  * Used by both MCP and CLI to execute Playwright code with persistent state.
4
4
  */
5
5
 
6
- import { Page, Frame, Browser, BrowserContext, chromium, Locator, FrameLocator, ElementHandle } from '@xmorse/playwright-core'
6
+ import type { Page, Frame, Browser, BrowserContext, Locator, FrameLocator, ElementHandle } from '@xmorse/playwright-core'
7
+ import { getChromium, isPatchrightEnabled } from './playwright-import.js'
7
8
  import crypto from 'node:crypto'
8
9
  import fs from 'node:fs'
9
10
  import path from 'node:path'
@@ -14,7 +15,7 @@ import { fileURLToPath } from 'node:url'
14
15
  import vm from 'node:vm'
15
16
  import * as acorn from 'acorn'
16
17
  import { createSmartDiff } from './diff-utils.js'
17
- import { getCdpUrl, parseRelayHost } from './utils.js'
18
+ import { getCdpUrl, parseRelayHost, shouldAutoEnablePlaywriter } from './utils.js'
18
19
  import { getExtensionOutdatedWarning } from './relay-client.js'
19
20
  import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js'
20
21
  import { ICDPSession, getCDPSessionForPage } from './cdp-session.js'
@@ -39,6 +40,7 @@ import { createDemoVideo } from './ffmpeg.js'
39
40
  import { type GhostCursorClientOptions } from './ghost-cursor.js'
40
41
  import { GhostCursorController } from './ghost-cursor-controller.js'
41
42
 
43
+
42
44
  const __filename = fileURLToPath(import.meta.url)
43
45
  const __dirname = path.dirname(__filename)
44
46
 
@@ -147,10 +149,45 @@ export function wrapCode(code: string): string {
147
149
 
148
150
  const EXTENSION_NOT_CONNECTED_ERROR = `The Playwriter Chrome extension is not connected. Make sure you have:
149
151
  1. Installed the extension: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe
150
- 2. Clicked the extension icon on a tab to enable it (or refreshed the page if just installed)`
152
+ 2. Clicked the extension icon on a tab to enable it (or refreshed the page if just installed)
153
+ 3. Or use a cloud browser instead: run \`playwriter cloud login\` in your terminal to rent a browser in the cloud, with auto CAPTCHA solving, residential proxies and anti-detection built in`
151
154
 
152
155
  const NO_PAGES_AVAILABLE_ERROR =
153
- 'No Playwright pages are available. Enable Playwriter on a tab or set PLAYWRITER_AUTO_ENABLE=1 to auto-create one.'
156
+ 'No Playwright pages are available. Enable Playwriter on a tab or unset PLAYWRITER_AUTO_ENABLE=false to auto-create one.'
157
+
158
+ const CLOUD_SESSION_EXPIRED_ERROR =
159
+ 'Cloud browser session expired or was destroyed. Create a new session with: playwriter session new --browser cloud'
160
+
161
+ /** Patterns that indicate the browser/page/context was closed or the WebSocket died.
162
+ * Used to detect cloud VM expiration vs other Playwright errors. */
163
+ const DISCONNECTION_PATTERNS = [
164
+ 'browser has been closed',
165
+ 'browser.close',
166
+ 'Target page, context or browser has been closed',
167
+ 'Target closed',
168
+ 'connection refused',
169
+ 'WebSocket is not open',
170
+ 'WebSocket error',
171
+ 'connect ECONNREFUSED',
172
+ 'Session closed',
173
+ 'Connection closed',
174
+ 'NS_ERROR_NET_RESET',
175
+ ]
176
+
177
+ function isDisconnectionError(error: Error): boolean {
178
+ const msg = error.message || ''
179
+ const stack = error.stack || ''
180
+ const matchesHere = DISCONNECTION_PATTERNS.some((pattern) => {
181
+ return msg.includes(pattern) || stack.includes(pattern)
182
+ })
183
+ if (matchesHere) return true
184
+ // Walk the cause chain — ensureConnection wraps the real WebSocket error
185
+ // in a new Error with { cause }, so we need to check nested causes too.
186
+ if (error.cause instanceof Error) {
187
+ return isDisconnectionError(error.cause)
188
+ }
189
+ return false
190
+ }
154
191
 
155
192
  const MAX_LOGS_PER_PAGE = 5000
156
193
 
@@ -227,9 +264,11 @@ export interface CdpConfig {
227
264
  port?: number
228
265
  token?: string
229
266
  extensionId?: string | null
230
- autoEnable?: boolean
231
267
  /** Direct CDP WebSocket URL — bypasses relay + extension, connects straight to Chrome */
232
268
  directCdpUrl?: string
269
+ /** Launch a headless Chrome via chromium.launch() instead of connecting to an existing one.
270
+ * Uses direct Playwright browser management, no extension or relay CDP routing needed. */
271
+ headless?: boolean
233
272
  }
234
273
 
235
274
  export interface SessionMetadata {
@@ -247,12 +286,22 @@ export interface SessionInfo {
247
286
  cwd: string | null
248
287
  }
249
288
 
289
+ export interface CloudSessionInfo {
290
+ /** Timestamp (epoch ms) when the BU VM will hard-timeout */
291
+ timeoutAt?: number
292
+ /** Whether proxy is enabled — when true, images/video/fonts are blocked to save bandwidth.
293
+ * Set to false via --disable-proxy-bandwidth-acceleration to allow all resources. */
294
+ blockProxyResources?: boolean
295
+ }
296
+
250
297
  export interface ExecutorOptions {
251
298
  cdpConfig: CdpConfig
252
299
  sessionMetadata?: SessionMetadata
253
300
  logger?: ExecutorLogger
254
301
  /** Working directory for scoped fs access */
255
302
  cwd?: string
303
+ /** Set when this executor is connected to a cloud Browser Use VM */
304
+ cloudSession?: CloudSessionInfo
256
305
  }
257
306
 
258
307
  function isRegExp(value: any): value is RegExp {
@@ -320,12 +369,17 @@ export class PlaywrightExecutor {
320
369
  private hasWarnedExtensionOutdated = false
321
370
 
322
371
  private ghostCursorController: GhostCursorController
372
+ /** Non-null when this executor is backed by a cloud Browser Use VM */
373
+ private cloudSession: CloudSessionInfo | null
374
+ /** Last minute bucket for which a cloud timeout warning was enqueued (dedup) */
375
+ private lastCloudTimeoutWarningMinute: number | null = null
323
376
 
324
377
  constructor(options: ExecutorOptions) {
325
378
  this.cdpConfig = options.cdpConfig
326
379
  this.logger = options.logger || { log: console.log, error: console.error }
327
380
  this.sessionMetadata = options.sessionMetadata || { extensionId: null, browser: null, profile: null }
328
381
  this.sessionCwd = options.cwd ? path.resolve(options.cwd) : null
382
+ this.cloudSession = options.cloudSession || null
329
383
  // ScopedFS expects an array of allowed directories. If cwd is provided, use it; otherwise use defaults.
330
384
  this.scopedFs = new ScopedFS(
331
385
  this.sessionCwd ? [this.sessionCwd, '/tmp', os.tmpdir()] : undefined,
@@ -377,6 +431,43 @@ export class PlaywrightExecutor {
377
431
  options.deviceScaleFactor = 2
378
432
  }
379
433
 
434
+ /** Block images, video, and font resources via Network.setBlockedURLs to save
435
+ * residential proxy bandwidth. Single CDP command, zero per-request overhead.
436
+ * Applied per-context on every page (existing and future). */
437
+ private async applyProxyResourceBlocking(context: BrowserContext): Promise<void> {
438
+ // URL patterns using the URLPattern spec syntax (absolute patterns).
439
+ // Covers the vast majority of image/video/font resources by file extension.
440
+ const blockedPatterns = [
441
+ // Images (SVGs excluded — lightweight and often used for icons/UI)
442
+ '*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.ico', '*.bmp', '*.avif',
443
+ ]
444
+
445
+ const applyToPage = async (page: Page) => {
446
+ try {
447
+ const cdpSession = await page.context().newCDPSession(page)
448
+ await cdpSession.send('Network.enable')
449
+ await cdpSession.send('Network.setBlockedURLs', {
450
+ urls: blockedPatterns,
451
+ })
452
+ await cdpSession.detach()
453
+ } catch (err) {
454
+ // Best-effort: don't break the session if blocking fails
455
+ this.logger.error('Failed to apply proxy resource blocking:', err)
456
+ }
457
+ }
458
+
459
+ // Apply to existing pages
460
+ const pages = context.pages().filter((p) => !p.isClosed())
461
+ await Promise.all(pages.map(applyToPage))
462
+
463
+ // Apply to future pages
464
+ context.on('page', (page) => {
465
+ applyToPage(page)
466
+ })
467
+
468
+ this.logger.log('Proxy bandwidth acceleration enabled: blocking raster images')
469
+ }
470
+
380
471
  private clearUserState() {
381
472
  Object.keys(this.userState).forEach((key) => delete this.userState[key])
382
473
  }
@@ -388,14 +479,24 @@ export class PlaywrightExecutor {
388
479
  this.context = null
389
480
  }
390
481
 
391
- private enqueueWarning(message: string) {
482
+ enqueueWarning(message: string) {
392
483
  this.nextWarningEventId += 1
393
484
  this.warningEvents.push({ id: this.nextWarningEventId, message })
394
485
  }
395
486
 
487
+ /** Update the cloud session timeout from external tracking (relay timer). */
488
+ updateCloudTimeout(timeoutAt: number) {
489
+ if (this.cloudSession) {
490
+ this.cloudSession.timeoutAt = timeoutAt
491
+ }
492
+ }
493
+
396
494
  private beginWarningScope(): WarningScope {
495
+ // Use lastDeliveredWarningEventId as cursor (not nextWarningEventId) so
496
+ // warnings enqueued by the relay interval between execute() calls are
497
+ // picked up by the next scope. Using nextWarningEventId would skip them.
397
498
  const scope: WarningScope = {
398
- cursor: this.nextWarningEventId,
499
+ cursor: this.lastDeliveredWarningEventId,
399
500
  }
400
501
  this.activeWarningScopes.add(scope)
401
502
  return scope
@@ -673,14 +774,25 @@ export class PlaywrightExecutor {
673
774
  return !!this.cdpConfig.directCdpUrl
674
775
  }
675
776
 
777
+ private isHeadlessMode(): boolean {
778
+ return !!this.cdpConfig.headless
779
+ }
780
+
676
781
  /**
677
782
  * Connect to Chrome and set up context/page. Shared by ensureConnection and reset.
783
+ * In headless mode, launches Chrome via chromium.launch().
678
784
  * In direct CDP mode, connects straight to Chrome's WebSocket.
679
785
  * In extension mode, checks extension status then connects via relay.
680
786
  */
681
787
  private async connectToBrowser(): Promise<{ browser: Browser; page: Page; context: BrowserContext }> {
788
+ // Headless mode: launch Chrome directly via Playwright (no extension, no relay CDP routing)
789
+ if (this.isHeadlessMode()) {
790
+ return this.connectHeadlessBrowser()
791
+ }
792
+
682
793
  if (this.isDirectCdpMode()) {
683
794
  // Direct CDP: connect straight to Chrome, no relay or extension needed
795
+ const chromium = await getChromium()
684
796
  const browser = await chromium.connectOverCDP(this.cdpConfig.directCdpUrl!)
685
797
 
686
798
  browser.on('disconnected', () => {
@@ -708,6 +820,13 @@ export class PlaywrightExecutor {
708
820
 
709
821
  await this.setDeviceScaleFactorForMacOS(context)
710
822
 
823
+ // Block images, video, and fonts for cloud sessions with proxy enabled
824
+ // to reduce residential proxy bandwidth costs. Uses Network.setBlockedURLs
825
+ // which is a single fire-and-forget CDP command with zero per-request overhead.
826
+ if (this.cloudSession?.blockProxyResources) {
827
+ await this.applyProxyResourceBlocking(context)
828
+ }
829
+
711
830
  return { browser, page, context }
712
831
  }
713
832
 
@@ -719,6 +838,7 @@ export class PlaywrightExecutor {
719
838
  this.warnIfExtensionOutdated(extensionStatus.playwriterVersion)
720
839
 
721
840
  const cdpUrl = getCdpUrl(this.cdpConfig)
841
+ const chromium = await getChromium()
722
842
  const browser = await chromium.connectOverCDP(cdpUrl)
723
843
 
724
844
  browser.on('disconnected', () => {
@@ -747,19 +867,128 @@ export class PlaywrightExecutor {
747
867
  return { browser, page, context }
748
868
  }
749
869
 
750
- private async ensureConnection(): Promise<{ browser: Browser; page: Page }> {
751
- if (this.isConnected && this.browser && this.page) {
752
- return { browser: this.browser, page: this.page }
870
+ /**
871
+ * Launch a headless Chrome via chromium.launch(). No extension, no relay CDP routing.
872
+ * Reuses an existing shared browser if one was already launched for headless mode.
873
+ * Does NOT add per-session disconnect listeners to avoid accumulation on the shared
874
+ * browser; instead, ensureConnection checks browser.isConnected() on each call.
875
+ */
876
+ private async connectHeadlessBrowser(): Promise<{ browser: Browser; page: Page; context: BrowserContext }> {
877
+ const browser = await PlaywrightExecutor.getOrLaunchHeadlessBrowser()
878
+
879
+ const context = await browser.newContext()
880
+ context.setDefaultTimeout(60000)
881
+ context.setDefaultNavigationTimeout(10000)
882
+
883
+ context.on('page', (page) => {
884
+ this.setupPageListeners(page)
885
+ })
886
+
887
+ const page = await context.newPage()
888
+ this.setupPageListeners(page)
889
+
890
+ await this.setDeviceScaleFactorForMacOS(context)
891
+
892
+ return { browser, page, context }
893
+ }
894
+
895
+ /** Shared headless browser instance across all headless sessions.
896
+ * Uses a launch promise to prevent concurrent first-session races from
897
+ * spawning multiple browsers. The disconnect handler is registered once
898
+ * at launch time and clears both statics so the next session relaunches. */
899
+ private static _sharedHeadlessBrowser: Browser | null = null
900
+ private static _sharedHeadlessBrowserPromise: Promise<Browser> | null = null
901
+
902
+ private static async getOrLaunchHeadlessBrowser(): Promise<Browser> {
903
+ // Check the cached browser is actually alive (not just non-null after a crash)
904
+ if (PlaywrightExecutor._sharedHeadlessBrowser?.isConnected()) {
905
+ return PlaywrightExecutor._sharedHeadlessBrowser
753
906
  }
754
907
 
755
- const { browser, page, context } = await this.connectToBrowser()
908
+ // Deduplicate concurrent launches: second caller awaits the first's promise
909
+ if (PlaywrightExecutor._sharedHeadlessBrowserPromise) {
910
+ return PlaywrightExecutor._sharedHeadlessBrowserPromise
911
+ }
756
912
 
757
- this.browser = browser
758
- this.page = page
759
- this.context = context
760
- this.isConnected = true
913
+ const launchPromise = (async () => {
914
+ const chromium = await getChromium()
915
+ const { resolveBrowserExecutablePath } = await import('./browser-config.js')
916
+ const executablePath = resolveBrowserExecutablePath()
917
+
918
+ const browser = await chromium.launch({
919
+ headless: true,
920
+ executablePath,
921
+ })
922
+
923
+ // Single handler registered once per browser lifetime.
924
+ // Clears both statics so the next headless session relaunches.
925
+ browser.on('disconnected', () => {
926
+ PlaywrightExecutor._sharedHeadlessBrowser = null
927
+ PlaywrightExecutor._sharedHeadlessBrowserPromise = null
928
+ })
929
+
930
+ PlaywrightExecutor._sharedHeadlessBrowser = browser
931
+ // Clear the promise now that the browser is cached; future callers
932
+ // use _sharedHeadlessBrowser directly. Concurrent waiters already
933
+ // hold a reference to launchPromise so they still resolve correctly.
934
+ PlaywrightExecutor._sharedHeadlessBrowserPromise = null
935
+ return browser
936
+ })()
937
+
938
+ PlaywrightExecutor._sharedHeadlessBrowserPromise = launchPromise
939
+ try {
940
+ return await launchPromise
941
+ } catch (error) {
942
+ PlaywrightExecutor._sharedHeadlessBrowserPromise = null
943
+ throw error
944
+ }
945
+ }
946
+
947
+ /** Close the headless context for this session (called on session delete). */
948
+ async closeHeadlessContext(): Promise<void> {
949
+ if (!this.isHeadlessMode() || !this.context) {
950
+ return
951
+ }
952
+ await this.context.close().catch((e) => {
953
+ this.logger.error('Error closing headless context:', e)
954
+ })
955
+ this.clearConnectionState()
956
+ }
957
+
958
+ /** Close the shared headless browser (called on relay shutdown). */
959
+ static async closeSharedHeadlessBrowser(): Promise<void> {
960
+ if (PlaywrightExecutor._sharedHeadlessBrowser) {
961
+ await PlaywrightExecutor._sharedHeadlessBrowser.close().catch(() => {})
962
+ PlaywrightExecutor._sharedHeadlessBrowser = null
963
+ PlaywrightExecutor._sharedHeadlessBrowserPromise = null
964
+ }
965
+ }
966
+
967
+ private async ensureConnection(): Promise<{ browser: Browser; page: Page }> {
968
+ // In headless mode, also check the shared browser is still alive.
969
+ // After a crash, isConnected() returns false and we need to reconnect.
970
+ const browserAlive = this.isHeadlessMode() ? this.browser?.isConnected() : true
971
+ if (this.isConnected && this.browser && this.page && browserAlive) {
972
+ return { browser: this.browser, page: this.page }
973
+ }
761
974
 
762
- return { browser, page }
975
+ try {
976
+ const { browser, page, context } = await this.connectToBrowser()
977
+
978
+ this.browser = browser
979
+ this.page = page
980
+ this.context = context
981
+ this.isConnected = true
982
+
983
+ return { browser, page }
984
+ } catch (error) {
985
+ // Cloud sessions that fail to connect are likely expired VMs.
986
+ // Give a clear error instead of a cryptic WebSocket/connection error.
987
+ if (this.cloudSession && error instanceof Error && isDisconnectionError(error)) {
988
+ throw new Error(CLOUD_SESSION_EXPIRED_ERROR, { cause: error })
989
+ }
990
+ throw error
991
+ }
763
992
  }
764
993
 
765
994
  private async getCurrentPage(timeout = 10000): Promise<Page> {
@@ -789,15 +1018,23 @@ export class PlaywrightExecutor {
789
1018
  }
790
1019
 
791
1020
  async reset(): Promise<{ page: Page; context: BrowserContext }> {
792
- if (this.browser) {
793
- this.suppressPageCloseWarnings = true
794
- try {
1021
+ this.suppressPageCloseWarnings = true
1022
+ try {
1023
+ if (this.isHeadlessMode()) {
1024
+ // In headless mode, only close this session's context, not the shared browser.
1025
+ // Other headless sessions share the same browser instance.
1026
+ if (this.context) {
1027
+ await this.context.close().catch((e) => {
1028
+ this.logger.error('Error closing context:', e)
1029
+ })
1030
+ }
1031
+ } else if (this.browser) {
795
1032
  await this.browser.close()
796
- } catch (e) {
797
- this.logger.error('Error closing browser:', e)
798
- } finally {
799
- this.suppressPageCloseWarnings = false
800
1033
  }
1034
+ } catch (e) {
1035
+ this.logger.error('Error closing browser:', e)
1036
+ } finally {
1037
+ this.suppressPageCloseWarnings = false
801
1038
  }
802
1039
 
803
1040
  this.clearConnectionState()
@@ -841,6 +1078,24 @@ export class PlaywrightExecutor {
841
1078
  }
842
1079
 
843
1080
  try {
1081
+ // Warn if cloud VM is approaching its hard timeout (deduped by minute bucket)
1082
+ if (this.cloudSession?.timeoutAt) {
1083
+ const remainingMs = this.cloudSession.timeoutAt - Date.now()
1084
+ if (remainingMs <= 0) {
1085
+ throw new Error(CLOUD_SESSION_EXPIRED_ERROR)
1086
+ }
1087
+ if (remainingMs < 5 * 60_000) {
1088
+ const mins = Math.ceil(remainingMs / 60_000)
1089
+ if (this.lastCloudTimeoutWarningMinute !== mins) {
1090
+ this.lastCloudTimeoutWarningMinute = mins
1091
+ this.enqueueWarning(
1092
+ `Cloud browser expires in ~${mins} minute${mins === 1 ? '' : 's'}. ` +
1093
+ `Create a new session soon with: playwriter session new --browser cloud`,
1094
+ )
1095
+ }
1096
+ }
1097
+ }
1098
+
844
1099
  await this.ensureConnection()
845
1100
  const page = await this.getCurrentPage(timeout)
846
1101
  const context = this.context || page.context()
@@ -1238,6 +1493,7 @@ export class PlaywrightExecutor {
1238
1493
  return typed.result
1239
1494
  })
1240
1495
 
1496
+
1241
1497
  let vmContextObj: any = {
1242
1498
  page,
1243
1499
  context,
@@ -1403,9 +1659,17 @@ export class PlaywrightExecutor {
1403
1659
 
1404
1660
  const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)')
1405
1661
  const warningText = this.flushWarningsForScope(warningScope)
1406
- const resetHint = isTimeoutError
1407
- ? ''
1408
- : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]'
1662
+
1663
+ // Cloud sessions: disconnection errors mean the VM expired or was destroyed.
1664
+ // Give a clear actionable message instead of a generic "call reset" hint.
1665
+ const isDisconnect = error instanceof Error && isDisconnectionError(error)
1666
+ const resetHint = (() => {
1667
+ if (isTimeoutError) return ''
1668
+ if (this.cloudSession && isDisconnect) {
1669
+ return `\n\n[Cloud browser expired or disconnected. Create a new session with: playwriter session new --browser cloud]`
1670
+ }
1671
+ return '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]'
1672
+ })()
1409
1673
 
1410
1674
  // timeout stacks are internal noise (Promise.race / setTimeout); only show the message
1411
1675
  const errorText = isTimeoutError ? error.message : errorStack
@@ -1418,7 +1682,7 @@ export class PlaywrightExecutor {
1418
1682
  }
1419
1683
  }
1420
1684
 
1421
- // When extension is connected but has no pages, auto-create only if PLAYWRITER_AUTO_ENABLE is set.
1685
+ // When extension is connected but has no pages, auto-create unless PLAYWRITER_AUTO_ENABLE=false disables it.
1422
1686
  // In direct CDP mode, always create a page (no extension check needed).
1423
1687
  private async ensurePageForContext(options: { context: BrowserContext; timeout: number }): Promise<Page> {
1424
1688
  const { context, timeout } = options
@@ -1440,7 +1704,7 @@ export class PlaywrightExecutor {
1440
1704
  throw new Error(EXTENSION_NOT_CONNECTED_ERROR)
1441
1705
  }
1442
1706
 
1443
- if (!process.env.PLAYWRITER_AUTO_ENABLE) {
1707
+ if (!shouldAutoEnablePlaywriter()) {
1444
1708
  const waitTimeoutMs = Math.min(timeout, 1000)
1445
1709
  const startTime = Date.now()
1446
1710
  while (Date.now() - startTime < waitTimeoutMs) {
@@ -1514,6 +1778,8 @@ export class ExecutorManager {
1514
1778
  sessionMetadata?: SessionMetadata
1515
1779
  /** Override cdpConfig for this session (e.g. direct CDP connection) */
1516
1780
  cdpConfig?: CdpConfig
1781
+ /** Cloud session info (set when connecting to a Browser Use VM) */
1782
+ cloudSession?: CloudSessionInfo
1517
1783
  }): PlaywrightExecutor {
1518
1784
  const { sessionId, cwd, sessionMetadata } = options
1519
1785
  let executor = this.executors.get(sessionId)
@@ -1534,6 +1800,7 @@ export class ExecutorManager {
1534
1800
  sessionMetadata,
1535
1801
  logger: this.logger,
1536
1802
  cwd,
1803
+ cloudSession: options.cloudSession,
1537
1804
  })
1538
1805
  this.executors.set(sessionId, executor)
1539
1806
  }