playwriter 0.3.1 → 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 (51) 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 +254 -18
  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 +568 -6
  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 -2
  22. package/dist/executor.d.ts.map +1 -1
  23. package/dist/executor.js +245 -22
  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-state.d.ts +1 -0
  34. package/dist/relay-state.d.ts.map +1 -1
  35. package/dist/relay-state.js +18 -0
  36. package/dist/relay-state.js.map +1 -1
  37. package/dist/relay-state.test.js +22 -0
  38. package/dist/relay-state.test.js.map +1 -1
  39. package/dist/selector-generator.js +1 -1
  40. package/package.json +3 -1
  41. package/src/browser-config.ts +11 -2
  42. package/src/browser-install.ts +283 -0
  43. package/src/cdp-relay.ts +300 -19
  44. package/src/chrome-discovery.ts +9 -0
  45. package/src/cli.ts +635 -7
  46. package/src/cloud-client.ts +172 -0
  47. package/src/executor.ts +291 -23
  48. package/src/playwright-import.ts +58 -0
  49. package/src/relay-state.test.ts +32 -0
  50. package/src/relay-state.ts +19 -1
  51. package/src/skill.md +154 -14
@@ -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'
@@ -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,11 +149,46 @@ 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
156
  'No Playwright pages are available. Enable Playwriter on a tab or unset PLAYWRITER_AUTO_ENABLE=false to auto-create one.'
154
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
+ }
191
+
155
192
  const MAX_LOGS_PER_PAGE = 5000
156
193
 
157
194
  const ALLOWED_MODULES = new Set([
@@ -229,6 +266,9 @@ export interface CdpConfig {
229
266
  extensionId?: string | null
230
267
  /** Direct CDP WebSocket URL — bypasses relay + extension, connects straight to Chrome */
231
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
232
272
  }
233
273
 
234
274
  export interface SessionMetadata {
@@ -246,12 +286,22 @@ export interface SessionInfo {
246
286
  cwd: string | null
247
287
  }
248
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
+
249
297
  export interface ExecutorOptions {
250
298
  cdpConfig: CdpConfig
251
299
  sessionMetadata?: SessionMetadata
252
300
  logger?: ExecutorLogger
253
301
  /** Working directory for scoped fs access */
254
302
  cwd?: string
303
+ /** Set when this executor is connected to a cloud Browser Use VM */
304
+ cloudSession?: CloudSessionInfo
255
305
  }
256
306
 
257
307
  function isRegExp(value: any): value is RegExp {
@@ -319,12 +369,17 @@ export class PlaywrightExecutor {
319
369
  private hasWarnedExtensionOutdated = false
320
370
 
321
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
322
376
 
323
377
  constructor(options: ExecutorOptions) {
324
378
  this.cdpConfig = options.cdpConfig
325
379
  this.logger = options.logger || { log: console.log, error: console.error }
326
380
  this.sessionMetadata = options.sessionMetadata || { extensionId: null, browser: null, profile: null }
327
381
  this.sessionCwd = options.cwd ? path.resolve(options.cwd) : null
382
+ this.cloudSession = options.cloudSession || null
328
383
  // ScopedFS expects an array of allowed directories. If cwd is provided, use it; otherwise use defaults.
329
384
  this.scopedFs = new ScopedFS(
330
385
  this.sessionCwd ? [this.sessionCwd, '/tmp', os.tmpdir()] : undefined,
@@ -376,6 +431,43 @@ export class PlaywrightExecutor {
376
431
  options.deviceScaleFactor = 2
377
432
  }
378
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
+
379
471
  private clearUserState() {
380
472
  Object.keys(this.userState).forEach((key) => delete this.userState[key])
381
473
  }
@@ -387,14 +479,24 @@ export class PlaywrightExecutor {
387
479
  this.context = null
388
480
  }
389
481
 
390
- private enqueueWarning(message: string) {
482
+ enqueueWarning(message: string) {
391
483
  this.nextWarningEventId += 1
392
484
  this.warningEvents.push({ id: this.nextWarningEventId, message })
393
485
  }
394
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
+
395
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.
396
498
  const scope: WarningScope = {
397
- cursor: this.nextWarningEventId,
499
+ cursor: this.lastDeliveredWarningEventId,
398
500
  }
399
501
  this.activeWarningScopes.add(scope)
400
502
  return scope
@@ -672,14 +774,25 @@ export class PlaywrightExecutor {
672
774
  return !!this.cdpConfig.directCdpUrl
673
775
  }
674
776
 
777
+ private isHeadlessMode(): boolean {
778
+ return !!this.cdpConfig.headless
779
+ }
780
+
675
781
  /**
676
782
  * Connect to Chrome and set up context/page. Shared by ensureConnection and reset.
783
+ * In headless mode, launches Chrome via chromium.launch().
677
784
  * In direct CDP mode, connects straight to Chrome's WebSocket.
678
785
  * In extension mode, checks extension status then connects via relay.
679
786
  */
680
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
+
681
793
  if (this.isDirectCdpMode()) {
682
794
  // Direct CDP: connect straight to Chrome, no relay or extension needed
795
+ const chromium = await getChromium()
683
796
  const browser = await chromium.connectOverCDP(this.cdpConfig.directCdpUrl!)
684
797
 
685
798
  browser.on('disconnected', () => {
@@ -707,6 +820,13 @@ export class PlaywrightExecutor {
707
820
 
708
821
  await this.setDeviceScaleFactorForMacOS(context)
709
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
+
710
830
  return { browser, page, context }
711
831
  }
712
832
 
@@ -718,6 +838,7 @@ export class PlaywrightExecutor {
718
838
  this.warnIfExtensionOutdated(extensionStatus.playwriterVersion)
719
839
 
720
840
  const cdpUrl = getCdpUrl(this.cdpConfig)
841
+ const chromium = await getChromium()
721
842
  const browser = await chromium.connectOverCDP(cdpUrl)
722
843
 
723
844
  browser.on('disconnected', () => {
@@ -746,19 +867,128 @@ export class PlaywrightExecutor {
746
867
  return { browser, page, context }
747
868
  }
748
869
 
749
- private async ensureConnection(): Promise<{ browser: Browser; page: Page }> {
750
- if (this.isConnected && this.browser && this.page) {
751
- 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
752
906
  }
753
907
 
754
- 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
+ }
755
912
 
756
- this.browser = browser
757
- this.page = page
758
- this.context = context
759
- 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
+ })()
760
937
 
761
- return { browser, page }
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
+ }
974
+
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
+ }
762
992
  }
763
993
 
764
994
  private async getCurrentPage(timeout = 10000): Promise<Page> {
@@ -788,15 +1018,23 @@ export class PlaywrightExecutor {
788
1018
  }
789
1019
 
790
1020
  async reset(): Promise<{ page: Page; context: BrowserContext }> {
791
- if (this.browser) {
792
- this.suppressPageCloseWarnings = true
793
- 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) {
794
1032
  await this.browser.close()
795
- } catch (e) {
796
- this.logger.error('Error closing browser:', e)
797
- } finally {
798
- this.suppressPageCloseWarnings = false
799
1033
  }
1034
+ } catch (e) {
1035
+ this.logger.error('Error closing browser:', e)
1036
+ } finally {
1037
+ this.suppressPageCloseWarnings = false
800
1038
  }
801
1039
 
802
1040
  this.clearConnectionState()
@@ -840,6 +1078,24 @@ export class PlaywrightExecutor {
840
1078
  }
841
1079
 
842
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
+
843
1099
  await this.ensureConnection()
844
1100
  const page = await this.getCurrentPage(timeout)
845
1101
  const context = this.context || page.context()
@@ -1237,6 +1493,7 @@ export class PlaywrightExecutor {
1237
1493
  return typed.result
1238
1494
  })
1239
1495
 
1496
+
1240
1497
  let vmContextObj: any = {
1241
1498
  page,
1242
1499
  context,
@@ -1402,9 +1659,17 @@ export class PlaywrightExecutor {
1402
1659
 
1403
1660
  const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)')
1404
1661
  const warningText = this.flushWarningsForScope(warningScope)
1405
- const resetHint = isTimeoutError
1406
- ? ''
1407
- : '\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
+ })()
1408
1673
 
1409
1674
  // timeout stacks are internal noise (Promise.race / setTimeout); only show the message
1410
1675
  const errorText = isTimeoutError ? error.message : errorStack
@@ -1513,6 +1778,8 @@ export class ExecutorManager {
1513
1778
  sessionMetadata?: SessionMetadata
1514
1779
  /** Override cdpConfig for this session (e.g. direct CDP connection) */
1515
1780
  cdpConfig?: CdpConfig
1781
+ /** Cloud session info (set when connecting to a Browser Use VM) */
1782
+ cloudSession?: CloudSessionInfo
1516
1783
  }): PlaywrightExecutor {
1517
1784
  const { sessionId, cwd, sessionMetadata } = options
1518
1785
  let executor = this.executors.get(sessionId)
@@ -1533,6 +1800,7 @@ export class ExecutorManager {
1533
1800
  sessionMetadata,
1534
1801
  logger: this.logger,
1535
1802
  cwd,
1803
+ cloudSession: options.cloudSession,
1536
1804
  })
1537
1805
  this.executors.set(sessionId, executor)
1538
1806
  }