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.
- 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 +261 -29
- 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 +578 -17
- 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 -3
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +249 -26
- 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-session.test.js +1 -1
- package/dist/relay-session.test.js.map +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/dist/utils.d.ts +2 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +4 -4
- package/dist/utils.js.map +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 +306 -32
- package/src/chrome-discovery.ts +9 -0
- package/src/cli.ts +645 -19
- package/src/cloud-client.ts +172 -0
- package/src/executor.ts +295 -28
- package/src/playwright-import.ts +58 -0
- package/src/relay-session.test.ts +1 -1
- package/src/relay-state.test.ts +32 -0
- package/src/relay-state.ts +19 -1
- package/src/skill.md +154 -14
- 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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
908
|
+
// Deduplicate concurrent launches: second caller awaits the first's promise
|
|
909
|
+
if (PlaywrightExecutor._sharedHeadlessBrowserPromise) {
|
|
910
|
+
return PlaywrightExecutor._sharedHeadlessBrowserPromise
|
|
911
|
+
}
|
|
756
912
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
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
|
|
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 (!
|
|
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
|
}
|