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.
- package/dist/bippy.js +5 -5
- package/dist/browser-config.d.ts.map +1 -1
- package/dist/browser-config.js +8 -2
- package/dist/browser-config.js.map +1 -1
- package/dist/browser-install.d.ts +16 -0
- package/dist/browser-install.d.ts.map +1 -0
- package/dist/browser-install.js +237 -0
- package/dist/browser-install.js.map +1 -0
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +254 -18
- package/dist/cdp-relay.js.map +1 -1
- package/dist/chrome-discovery.d.ts.map +1 -1
- package/dist/chrome-discovery.js +8 -0
- package/dist/chrome-discovery.js.map +1 -1
- package/dist/cli.js +568 -6
- package/dist/cli.js.map +1 -1
- package/dist/cloud-client.d.ts +56 -0
- package/dist/cloud-client.d.ts.map +1 -0
- package/dist/cloud-client.js +120 -0
- package/dist/cloud-client.js.map +1 -0
- package/dist/executor.d.ts +46 -2
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +245 -22
- package/dist/executor.js.map +1 -1
- package/dist/extension/background.js +106 -23
- package/dist/extension/manifest.json +1 -1
- package/dist/playwright-import.d.ts +19 -0
- package/dist/playwright-import.d.ts.map +1 -0
- package/dist/playwright-import.js +39 -0
- package/dist/playwright-import.js.map +1 -0
- package/dist/prompt.md +32 -0
- package/dist/readability.js +1 -1
- package/dist/relay-state.d.ts +1 -0
- package/dist/relay-state.d.ts.map +1 -1
- package/dist/relay-state.js +18 -0
- package/dist/relay-state.js.map +1 -1
- package/dist/relay-state.test.js +22 -0
- package/dist/relay-state.test.js.map +1 -1
- package/dist/selector-generator.js +1 -1
- package/package.json +3 -1
- package/src/browser-config.ts +11 -2
- package/src/browser-install.ts +283 -0
- package/src/cdp-relay.ts +300 -19
- package/src/chrome-discovery.ts +9 -0
- package/src/cli.ts +635 -7
- package/src/cloud-client.ts +172 -0
- package/src/executor.ts +291 -23
- package/src/playwright-import.ts +58 -0
- package/src/relay-state.test.ts +32 -0
- package/src/relay-state.ts +19 -1
- 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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
908
|
+
// Deduplicate concurrent launches: second caller awaits the first's promise
|
|
909
|
+
if (PlaywrightExecutor._sharedHeadlessBrowserPromise) {
|
|
910
|
+
return PlaywrightExecutor._sharedHeadlessBrowserPromise
|
|
911
|
+
}
|
|
755
912
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
}
|