playwriter 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/bippy.js +5 -5
  2. package/dist/browser-config.d.ts.map +1 -1
  3. package/dist/browser-config.js +8 -2
  4. package/dist/browser-config.js.map +1 -1
  5. package/dist/browser-install.d.ts +16 -0
  6. package/dist/browser-install.d.ts.map +1 -0
  7. package/dist/browser-install.js +237 -0
  8. package/dist/browser-install.js.map +1 -0
  9. package/dist/cdp-relay.d.ts.map +1 -1
  10. package/dist/cdp-relay.js +261 -29
  11. package/dist/cdp-relay.js.map +1 -1
  12. package/dist/chrome-discovery.d.ts.map +1 -1
  13. package/dist/chrome-discovery.js +8 -0
  14. package/dist/chrome-discovery.js.map +1 -1
  15. package/dist/cli.js +578 -17
  16. package/dist/cli.js.map +1 -1
  17. package/dist/cloud-client.d.ts +56 -0
  18. package/dist/cloud-client.d.ts.map +1 -0
  19. package/dist/cloud-client.js +120 -0
  20. package/dist/cloud-client.js.map +1 -0
  21. package/dist/executor.d.ts +46 -3
  22. package/dist/executor.d.ts.map +1 -1
  23. package/dist/executor.js +249 -26
  24. package/dist/executor.js.map +1 -1
  25. package/dist/extension/background.js +106 -23
  26. package/dist/extension/manifest.json +1 -1
  27. package/dist/playwright-import.d.ts +19 -0
  28. package/dist/playwright-import.d.ts.map +1 -0
  29. package/dist/playwright-import.js +39 -0
  30. package/dist/playwright-import.js.map +1 -0
  31. package/dist/prompt.md +32 -0
  32. package/dist/readability.js +1 -1
  33. package/dist/relay-session.test.js +1 -1
  34. package/dist/relay-session.test.js.map +1 -1
  35. package/dist/relay-state.d.ts +1 -0
  36. package/dist/relay-state.d.ts.map +1 -1
  37. package/dist/relay-state.js +18 -0
  38. package/dist/relay-state.js.map +1 -1
  39. package/dist/relay-state.test.js +22 -0
  40. package/dist/relay-state.test.js.map +1 -1
  41. package/dist/selector-generator.js +1 -1
  42. package/dist/utils.d.ts +2 -2
  43. package/dist/utils.d.ts.map +1 -1
  44. package/dist/utils.js +4 -4
  45. package/dist/utils.js.map +1 -1
  46. package/package.json +3 -1
  47. package/src/browser-config.ts +11 -2
  48. package/src/browser-install.ts +283 -0
  49. package/src/cdp-relay.ts +306 -32
  50. package/src/chrome-discovery.ts +9 -0
  51. package/src/cli.ts +645 -19
  52. package/src/cloud-client.ts +172 -0
  53. package/src/executor.ts +295 -28
  54. package/src/playwright-import.ts +58 -0
  55. package/src/relay-session.test.ts +1 -1
  56. package/src/relay-state.test.ts +32 -0
  57. package/src/relay-state.ts +19 -1
  58. package/src/skill.md +154 -14
  59. package/src/utils.ts +4 -5
@@ -0,0 +1,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
+ }