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,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download and install Chrome for Testing into ~/.playwriter/browsers/.
|
|
3
|
+
* Similar to agent-browser's install command: fetches the latest stable
|
|
4
|
+
* Chrome for Testing build from Google's official automation channel.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { pipeline } from 'node:stream/promises'
|
|
11
|
+
import { Readable } from 'node:stream'
|
|
12
|
+
import { execFileSync } from 'node:child_process'
|
|
13
|
+
|
|
14
|
+
const LAST_KNOWN_GOOD_URL =
|
|
15
|
+
'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json'
|
|
16
|
+
|
|
17
|
+
export function getBrowsersDir(): string {
|
|
18
|
+
return path.join(os.homedir(), '.playwriter', 'browsers')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getPlatformKey(): string {
|
|
22
|
+
const platform = os.platform()
|
|
23
|
+
const arch = os.arch()
|
|
24
|
+
if (platform === 'darwin' && arch === 'arm64') {
|
|
25
|
+
return 'mac-arm64'
|
|
26
|
+
}
|
|
27
|
+
if (platform === 'darwin' && arch === 'x64') {
|
|
28
|
+
return 'mac-x64'
|
|
29
|
+
}
|
|
30
|
+
if (platform === 'linux' && arch === 'x64') {
|
|
31
|
+
return 'linux64'
|
|
32
|
+
}
|
|
33
|
+
if (platform === 'win32' && arch === 'x64') {
|
|
34
|
+
return 'win64'
|
|
35
|
+
}
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Unsupported platform for Chrome for Testing download: ${platform}/${arch}. ` +
|
|
38
|
+
`Install Chromium manually and use PLAYWRITER_BROWSER_PATH to point to it.`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Find the Chrome binary inside a downloaded Chrome for Testing directory.
|
|
44
|
+
*/
|
|
45
|
+
function findChromeBinaryInDir(dir: string): string | null {
|
|
46
|
+
const platform = os.platform()
|
|
47
|
+
|
|
48
|
+
if (platform === 'darwin') {
|
|
49
|
+
const candidates = [
|
|
50
|
+
path.join(dir, 'Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
|
|
51
|
+
path.join(dir, `chrome-${getPlatformKey()}/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing`),
|
|
52
|
+
]
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
if (fs.existsSync(candidate)) {
|
|
55
|
+
return candidate
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (platform === 'linux') {
|
|
61
|
+
const candidates = [
|
|
62
|
+
path.join(dir, 'chrome'),
|
|
63
|
+
path.join(dir, 'chrome-linux64/chrome'),
|
|
64
|
+
]
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
if (fs.existsSync(candidate)) {
|
|
67
|
+
return candidate
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (platform === 'win32') {
|
|
73
|
+
const candidates = [
|
|
74
|
+
path.join(dir, 'chrome.exe'),
|
|
75
|
+
path.join(dir, 'chrome-win64/chrome.exe'),
|
|
76
|
+
]
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
if (fs.existsSync(candidate)) {
|
|
79
|
+
return candidate
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Find Chrome installed by `playwriter install` in ~/.playwriter/browsers/.
|
|
89
|
+
* Returns the path to the Chrome binary, or null if not found.
|
|
90
|
+
*/
|
|
91
|
+
export function findInstalledChrome(): string | null {
|
|
92
|
+
const browsersDir = getBrowsersDir()
|
|
93
|
+
if (!fs.existsSync(browsersDir)) {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const entries = fs.readdirSync(browsersDir, { withFileTypes: true })
|
|
98
|
+
.filter((e) => {
|
|
99
|
+
return e.isDirectory() && e.name.startsWith('chrome-')
|
|
100
|
+
})
|
|
101
|
+
.sort((a, b) => {
|
|
102
|
+
// Sort descending so newest version is first
|
|
103
|
+
return b.name.localeCompare(a.name)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const dir = path.join(browsersDir, entry.name)
|
|
108
|
+
const binary = findChromeBinaryInDir(dir)
|
|
109
|
+
if (binary) {
|
|
110
|
+
return binary
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface VersionInfo {
|
|
118
|
+
version: string
|
|
119
|
+
downloadUrl: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function fetchDownloadUrl(): Promise<VersionInfo> {
|
|
123
|
+
const response = await fetch(LAST_KNOWN_GOOD_URL, {
|
|
124
|
+
signal: AbortSignal.timeout(30000),
|
|
125
|
+
})
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
throw new Error(`Failed to fetch Chrome version info: HTTP ${response.status}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const data = (await response.json()) as {
|
|
131
|
+
channels: {
|
|
132
|
+
Stable: {
|
|
133
|
+
version: string
|
|
134
|
+
downloads: {
|
|
135
|
+
chrome: Array<{ platform: string; url: string }>
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const channel = data.channels?.Stable
|
|
142
|
+
if (!channel) {
|
|
143
|
+
throw new Error('No Stable channel found in Chrome for Testing version info')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const platformKey = getPlatformKey()
|
|
147
|
+
const download = channel.downloads?.chrome?.find((d) => {
|
|
148
|
+
return d.platform === platformKey
|
|
149
|
+
})
|
|
150
|
+
if (!download) {
|
|
151
|
+
throw new Error(`No Chrome for Testing download found for platform: ${platformKey}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { version: channel.version, downloadUrl: download.url }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function downloadFile(url: string, destPath: string): Promise<void> {
|
|
158
|
+
// 5-minute timeout per attempt, 3 retries for transient errors
|
|
159
|
+
const maxAttempts = 3
|
|
160
|
+
let lastError: Error | null = null
|
|
161
|
+
|
|
162
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
163
|
+
try {
|
|
164
|
+
await downloadFileAttempt(url, destPath)
|
|
165
|
+
return
|
|
166
|
+
} catch (error) {
|
|
167
|
+
lastError = error instanceof Error ? error : new Error(String(error))
|
|
168
|
+
// Don't retry on 4xx (permanent errors)
|
|
169
|
+
if (lastError.message.includes('HTTP 4')) {
|
|
170
|
+
throw lastError
|
|
171
|
+
}
|
|
172
|
+
if (attempt < maxAttempts) {
|
|
173
|
+
const delay = attempt * 2000
|
|
174
|
+
console.log(` Download attempt ${attempt} failed, retrying in ${delay / 1000}s...`)
|
|
175
|
+
await new Promise((resolve) => {
|
|
176
|
+
return setTimeout(resolve, delay)
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
throw lastError!
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function downloadFileAttempt(url: string, destPath: string): Promise<void> {
|
|
185
|
+
const response = await fetch(url, {
|
|
186
|
+
signal: AbortSignal.timeout(300_000), // 5 minute timeout
|
|
187
|
+
})
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
throw new Error(`Download failed: HTTP ${response.status} for ${url}`)
|
|
190
|
+
}
|
|
191
|
+
if (!response.body) {
|
|
192
|
+
throw new Error('Download failed: no response body')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const totalBytes = Number(response.headers.get('content-length') || 0)
|
|
196
|
+
let downloadedBytes = 0
|
|
197
|
+
let lastPct = 0
|
|
198
|
+
|
|
199
|
+
const progressStream = new TransformStream({
|
|
200
|
+
transform(chunk, controller) {
|
|
201
|
+
downloadedBytes += chunk.byteLength
|
|
202
|
+
if (totalBytes > 0) {
|
|
203
|
+
const pct = Math.floor((downloadedBytes / totalBytes) * 100)
|
|
204
|
+
if (pct >= lastPct + 5) {
|
|
205
|
+
lastPct = pct
|
|
206
|
+
const mb = (downloadedBytes / 1_048_576).toFixed(0)
|
|
207
|
+
const totalMb = (totalBytes / 1_048_576).toFixed(0)
|
|
208
|
+
process.stderr.write(`\r ${mb}/${totalMb} MB (${pct}%)`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
controller.enqueue(chunk)
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const readableStream = response.body.pipeThrough(progressStream)
|
|
216
|
+
const nodeReadable = Readable.fromWeb(readableStream as any)
|
|
217
|
+
const writeStream = fs.createWriteStream(destPath)
|
|
218
|
+
await pipeline(nodeReadable, writeStream)
|
|
219
|
+
process.stderr.write('\n')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function extractZip(zipPath: string, destDir: string): void {
|
|
223
|
+
fs.mkdirSync(destDir, { recursive: true })
|
|
224
|
+
const platform = os.platform()
|
|
225
|
+
|
|
226
|
+
if (platform === 'win32') {
|
|
227
|
+
execFileSync('powershell', [
|
|
228
|
+
'-Command',
|
|
229
|
+
`Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force`,
|
|
230
|
+
], { stdio: 'pipe' })
|
|
231
|
+
} else {
|
|
232
|
+
execFileSync('unzip', ['-o', '-q', zipPath, '-d', destDir], { stdio: 'pipe' })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function installChrome(): Promise<{ version: string; binaryPath: string }> {
|
|
237
|
+
console.log('Fetching latest Chrome for Testing version...')
|
|
238
|
+
const { version, downloadUrl } = await fetchDownloadUrl()
|
|
239
|
+
|
|
240
|
+
const browsersDir = getBrowsersDir()
|
|
241
|
+
const destDir = path.join(browsersDir, `chrome-${version}`)
|
|
242
|
+
|
|
243
|
+
// Check if already installed
|
|
244
|
+
const existingBinary = findChromeBinaryInDir(destDir)
|
|
245
|
+
if (existingBinary) {
|
|
246
|
+
console.log(`Chrome ${version} is already installed.`)
|
|
247
|
+
console.log(` Location: ${existingBinary}`)
|
|
248
|
+
return { version, binaryPath: existingBinary }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
console.log(`Downloading Chrome ${version} for ${getPlatformKey()}...`)
|
|
252
|
+
console.log(` ${downloadUrl}`)
|
|
253
|
+
|
|
254
|
+
const tmpZip = path.join(os.tmpdir(), `playwriter-chrome-${version}.zip`)
|
|
255
|
+
try {
|
|
256
|
+
await downloadFile(downloadUrl, tmpZip)
|
|
257
|
+
console.log('Extracting...')
|
|
258
|
+
extractZip(tmpZip, destDir)
|
|
259
|
+
} finally {
|
|
260
|
+
// Clean up temp zip
|
|
261
|
+
try {
|
|
262
|
+
fs.unlinkSync(tmpZip)
|
|
263
|
+
} catch {
|
|
264
|
+
// ignore cleanup errors
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const binaryPath = findChromeBinaryInDir(destDir)
|
|
269
|
+
if (!binaryPath) {
|
|
270
|
+
// Clean up failed extraction
|
|
271
|
+
fs.rmSync(destDir, { recursive: true, force: true })
|
|
272
|
+
throw new Error('Chrome was downloaded but the binary could not be found in the extracted archive.')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Ensure binary is executable on Unix
|
|
276
|
+
if (os.platform() !== 'win32') {
|
|
277
|
+
fs.chmodSync(binaryPath, 0o755)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
console.log(`Chrome ${version} installed successfully.`)
|
|
281
|
+
console.log(` Location: ${binaryPath}`)
|
|
282
|
+
return { version, binaryPath }
|
|
283
|
+
}
|