playwriter 0.0.89 → 0.0.103
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/aria-snapshot.d.ts +7 -3
- package/dist/aria-snapshot.d.ts.map +1 -1
- package/dist/aria-snapshot.js +76 -18
- package/dist/aria-snapshot.js.map +1 -1
- package/dist/aria-snapshot.test.js +94 -0
- package/dist/aria-snapshot.test.js.map +1 -1
- package/dist/bippy.js +1 -1
- package/dist/browser-config.d.ts +12 -1
- package/dist/browser-config.d.ts.map +1 -1
- package/dist/browser-config.js +101 -38
- package/dist/browser-config.js.map +1 -1
- package/dist/browser-launch.d.ts +17 -0
- package/dist/browser-launch.d.ts.map +1 -0
- package/dist/browser-launch.js +44 -0
- package/dist/browser-launch.js.map +1 -0
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +34 -10
- package/dist/cdp-relay.js.map +1 -1
- package/dist/channel-owner-inspect.test.d.ts +2 -0
- package/dist/channel-owner-inspect.test.d.ts.map +1 -0
- package/dist/channel-owner-inspect.test.js +75 -0
- package/dist/channel-owner-inspect.test.js.map +1 -0
- package/dist/chrome-discovery.d.ts +65 -0
- package/dist/chrome-discovery.d.ts.map +1 -0
- package/dist/chrome-discovery.js +165 -0
- package/dist/chrome-discovery.js.map +1 -0
- package/dist/chrome-discovery.test.d.ts +2 -0
- package/dist/chrome-discovery.test.d.ts.map +1 -0
- package/dist/chrome-discovery.test.js +41 -0
- package/dist/chrome-discovery.test.js.map +1 -0
- package/dist/cli.js +357 -53
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts +44 -11
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +121 -55
- package/dist/executor.js.map +1 -1
- package/dist/executor.unit.test.js +29 -1
- package/dist/executor.unit.test.js.map +1 -1
- package/dist/extension/background.js +1704 -0
- package/dist/extension/icons/SolarCursorSquareBold.png +0 -0
- package/dist/extension/icons/icon-black-128.png +0 -0
- package/dist/extension/icons/icon-black-16.png +0 -0
- package/dist/extension/icons/icon-black-32.png +0 -0
- package/dist/extension/icons/icon-black-48.png +0 -0
- package/dist/extension/icons/icon-gray-128.png +0 -0
- package/dist/extension/icons/icon-gray-16.png +0 -0
- package/dist/extension/icons/icon-gray-32.png +0 -0
- package/dist/extension/icons/icon-gray-48.png +0 -0
- package/dist/extension/icons/icon-green-128.png +0 -0
- package/dist/extension/icons/icon-green-16.png +0 -0
- package/dist/extension/icons/icon-green-32.png +0 -0
- package/dist/extension/icons/icon-green-48.png +0 -0
- package/dist/extension/icons/playwriter-icon-black.png +0 -0
- package/dist/extension/icons/playwriter-icon-gray-disabled.png +0 -0
- package/dist/extension/icons/playwriter-icon-gray-disabled.svg +1 -0
- package/dist/extension/manifest.json +40 -0
- package/dist/extension/offscreen.js +214 -0
- package/dist/extension/src/offscreen.html +9 -0
- package/dist/extension/src/prism-bash.min.js +1 -0
- package/dist/extension/src/prism.min.js +1 -0
- package/dist/extension/src/welcome.html +399 -0
- package/dist/kitty-graphics.d.ts +22 -0
- package/dist/kitty-graphics.d.ts.map +1 -0
- package/dist/kitty-graphics.js +71 -0
- package/dist/kitty-graphics.js.map +1 -0
- package/dist/kitty-graphics.test.d.ts +2 -0
- package/dist/kitty-graphics.test.d.ts.map +1 -0
- package/dist/kitty-graphics.test.js +125 -0
- package/dist/kitty-graphics.test.js.map +1 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +76 -12
- package/dist/mcp.js.map +1 -1
- package/dist/package-paths.d.ts +3 -0
- package/dist/package-paths.d.ts.map +1 -0
- package/dist/package-paths.js +29 -0
- package/dist/package-paths.js.map +1 -0
- package/dist/prompt.md +4 -2
- package/dist/readability.js +1 -1
- package/dist/relay-core.test.js +14 -4
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-navigation.test.d.ts.map +1 -1
- package/dist/relay-navigation.test.js +95 -1
- package/dist/relay-navigation.test.js.map +1 -1
- package/dist/scoped-fs.d.ts +5 -4
- package/dist/scoped-fs.d.ts.map +1 -1
- package/dist/scoped-fs.js +11 -9
- package/dist/scoped-fs.js.map +1 -1
- package/dist/scoped-fs.test.d.ts +5 -0
- package/dist/scoped-fs.test.d.ts.map +1 -0
- package/dist/scoped-fs.test.js +70 -0
- package/dist/scoped-fs.test.js.map +1 -0
- package/dist/screen-recording.d.ts +3 -6
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +7 -21
- package/dist/screen-recording.js.map +1 -1
- package/dist/selector-generator.js +1 -1
- package/dist/snapshot-tools.test.js +3 -4
- package/dist/snapshot-tools.test.js.map +1 -1
- package/dist/test-utils.d.ts +3 -1
- package/dist/test-utils.d.ts.map +1 -1
- package/dist/test-utils.js +24 -12
- package/dist/test-utils.js.map +1 -1
- package/package.json +6 -5
- package/src/aria-snapshot.test.ts +110 -0
- package/src/aria-snapshot.ts +86 -19
- package/src/aria-snapshots/contenteditable-interactive.txt +7 -0
- package/src/aria-snapshots/contenteditable-raw.txt +14 -0
- package/src/aria-snapshots/github-interactive.txt +0 -2
- package/src/aria-snapshots/github-raw.txt +0 -4
- package/src/aria-snapshots/hackernews-interactive.txt +240 -237
- package/src/aria-snapshots/hackernews-raw.txt +262 -258
- package/src/aria-snapshots/prosemirror-interactive.txt +63 -0
- package/src/aria-snapshots/prosemirror-raw.txt +141 -0
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/browser-config.ts +143 -37
- package/src/browser-launch.ts +75 -0
- package/src/cdp-relay.ts +46 -11
- package/src/channel-owner-inspect.test.ts +94 -0
- package/src/chrome-discovery.test.ts +50 -0
- package/src/chrome-discovery.ts +211 -0
- package/src/cli.ts +432 -59
- package/src/executor.ts +171 -72
- package/src/executor.unit.test.ts +32 -1
- package/src/kitty-graphics.test.ts +139 -0
- package/src/kitty-graphics.ts +79 -0
- package/src/mcp.ts +82 -12
- package/src/package-paths.ts +36 -0
- package/src/relay-core.test.ts +14 -4
- package/src/relay-navigation.test.ts +106 -1
- package/src/scoped-fs.test.ts +77 -0
- package/src/scoped-fs.ts +12 -9
- package/src/screen-recording.ts +7 -22
- package/src/skill.md +32 -2
- package/src/snapshot-tools.test.ts +3 -4
- package/src/snapshots/shadcn-ui-accessibility-full.md +181 -183
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +119 -121
- package/src/test-utils.ts +25 -11
package/src/cli.ts
CHANGED
|
@@ -4,14 +4,23 @@ import fs from 'node:fs'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import util from 'node:util'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
-
import {
|
|
7
|
+
import { goke } from 'goke'
|
|
8
|
+
import { z } from 'zod'
|
|
8
9
|
import pc from 'picocolors'
|
|
10
|
+
import {
|
|
11
|
+
getBrowserLaunchArgs,
|
|
12
|
+
getDefaultBrowserUserDataDir,
|
|
13
|
+
startBrowserProcess,
|
|
14
|
+
} from './browser-launch.js'
|
|
15
|
+
import { resolveBrowserExecutablePath, shouldUseHeadlessByDefault } from './browser-config.js'
|
|
16
|
+
import { getBundledExtensionPath } from './package-paths.js'
|
|
9
17
|
|
|
10
18
|
// Prevent Buffers from dumping hex bytes in util.inspect output.
|
|
11
19
|
Buffer.prototype[util.inspect.custom] = function () {
|
|
12
20
|
return `<Buffer ${this.length} bytes>`
|
|
13
21
|
}
|
|
14
22
|
import { killPortProcess } from './kill-port.js'
|
|
23
|
+
import { canEmitKittyGraphics, emitKittyImage } from './kitty-graphics.js'
|
|
15
24
|
import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.js'
|
|
16
25
|
import {
|
|
17
26
|
ensureRelayServer,
|
|
@@ -21,12 +30,73 @@ import {
|
|
|
21
30
|
getExtensionStatus,
|
|
22
31
|
type ExtensionStatus,
|
|
23
32
|
} from './relay-client.js'
|
|
33
|
+
import { discoverChromeInstances, resolveDirectInput, type DiscoveredInstance } from './chrome-discovery.js'
|
|
24
34
|
|
|
25
35
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
26
36
|
|
|
27
37
|
const cliRelayEnv = { PLAYWRITER_AUTO_ENABLE: '1' }
|
|
28
38
|
|
|
29
|
-
const cli =
|
|
39
|
+
const cli = goke('playwriter')
|
|
40
|
+
|
|
41
|
+
cli
|
|
42
|
+
.command('browser start [binaryPath]', 'Start Chromium or Chrome for Testing with the bundled Playwriter extension')
|
|
43
|
+
.hidden()
|
|
44
|
+
.option('--user-data-dir <dir>', 'Persistent browser profile directory used for the managed browser')
|
|
45
|
+
.option('--headless', 'Run the browser in headless mode')
|
|
46
|
+
.option('--headed', 'Force headed mode even on Linux without DISPLAY/WAYLAND_DISPLAY')
|
|
47
|
+
.option('--disable-sandbox', 'Disable the browser sandbox, useful on some VPS setups')
|
|
48
|
+
.action(async (binaryPath, options) => {
|
|
49
|
+
if (options.headless && options.headed) {
|
|
50
|
+
console.error('Error: --headless and --headed cannot be used together.')
|
|
51
|
+
process.exit(1)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
|
56
|
+
|
|
57
|
+
const browserPath = resolveBrowserExecutablePath({ browserPath: binaryPath })
|
|
58
|
+
const extensionPath = getBundledExtensionPath()
|
|
59
|
+
const userDataDir = path.resolve(options.userDataDir || getDefaultBrowserUserDataDir())
|
|
60
|
+
const headless = options.headed ? false : options.headless ? true : shouldUseHeadlessByDefault()
|
|
61
|
+
const args = getBrowserLaunchArgs({
|
|
62
|
+
extensionPath,
|
|
63
|
+
userDataDir,
|
|
64
|
+
headless,
|
|
65
|
+
noSandbox: options.disableSandbox,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const { pid } = startBrowserProcess({
|
|
69
|
+
browserPath,
|
|
70
|
+
args,
|
|
71
|
+
userDataDir,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const connectedExtensions = await waitForConnectedExtensions({
|
|
75
|
+
timeoutMs: 15000,
|
|
76
|
+
pollIntervalMs: 250,
|
|
77
|
+
logger: console,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
console.log(`Browser started (pid ${pid}).`)
|
|
81
|
+
console.log(` Binary: ${browserPath}`)
|
|
82
|
+
console.log(` Extension: ${extensionPath}`)
|
|
83
|
+
console.log(` Profile: ${userDataDir}`)
|
|
84
|
+
console.log(` Mode: ${headless ? 'headless' : 'headed'}`)
|
|
85
|
+
console.log(' Permissions: recording/tabCapture flags enabled')
|
|
86
|
+
|
|
87
|
+
if (connectedExtensions.length > 0) {
|
|
88
|
+
console.log('Playwriter extension connected to the relay server.')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('Browser started, but the extension has not connected yet.')
|
|
93
|
+
console.log(`Check logs at: ${LOG_FILE_PATH}`)
|
|
94
|
+
} catch (error: any) {
|
|
95
|
+
console.error(`Error: ${error.message}`)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
)
|
|
30
100
|
|
|
31
101
|
cli
|
|
32
102
|
.command('', 'Start the MCP server or controls the browser with -e')
|
|
@@ -34,8 +104,8 @@ cli
|
|
|
34
104
|
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
35
105
|
.option('-s, --session <name>', 'Session ID (required for -e, get one with `playwriter session new`)')
|
|
36
106
|
.option('-e, --eval <code>', 'Execute JavaScript code and exit, read https://playwriter.dev/SKILL.md for usage')
|
|
37
|
-
.option('--timeout
|
|
38
|
-
.action(async (options
|
|
107
|
+
.option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
|
|
108
|
+
.action(async (options) => {
|
|
39
109
|
// If -e flag is provided, execute code via relay server
|
|
40
110
|
if (options.eval) {
|
|
41
111
|
await executeCode({
|
|
@@ -49,6 +119,7 @@ cli
|
|
|
49
119
|
}
|
|
50
120
|
|
|
51
121
|
// Otherwise start the MCP server
|
|
122
|
+
// For direct CDP in MCP mode, use PLAYWRITER_DIRECT env var
|
|
52
123
|
const { startMcp } = await import('./mcp.js')
|
|
53
124
|
await startMcp({
|
|
54
125
|
host: options.host,
|
|
@@ -171,6 +242,7 @@ async function executeCode(options: {
|
|
|
171
242
|
const result = (await response.json()) as {
|
|
172
243
|
text: string
|
|
173
244
|
images: Array<{ data: string; mimeType: string }>
|
|
245
|
+
screenshots: Array<{ path: string; base64: string; snapshot: string; labelCount: number }>
|
|
174
246
|
isError: boolean
|
|
175
247
|
}
|
|
176
248
|
|
|
@@ -183,9 +255,36 @@ async function executeCode(options: {
|
|
|
183
255
|
}
|
|
184
256
|
}
|
|
185
257
|
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
258
|
+
// Emit images via Kitty Graphics Protocol when AGENT_GRAPHICS=kitty.
|
|
259
|
+
// Agents with kitty-graphics-agent intercept these escape sequences and pass
|
|
260
|
+
// the PNG images to the LLM as media parts — no extra tool call needed.
|
|
261
|
+
const kittyEnabled = canEmitKittyGraphics()
|
|
262
|
+
|
|
263
|
+
// Track emitted base64 to avoid duplicates (screenshots appear in both
|
|
264
|
+
// result.screenshots and result.images from the same screenshotCollector)
|
|
265
|
+
const emittedImages = new Set<string>()
|
|
266
|
+
|
|
267
|
+
if (result.screenshots && result.screenshots.length > 0) {
|
|
268
|
+
for (const s of result.screenshots) {
|
|
269
|
+
if (kittyEnabled && s.base64) {
|
|
270
|
+
emitKittyImage({ base64: s.base64 })
|
|
271
|
+
emittedImages.add(s.base64)
|
|
272
|
+
}
|
|
273
|
+
console.log(`\nScreenshot saved to: ${s.path}`)
|
|
274
|
+
console.log(`Labels shown: ${s.labelCount}\n`)
|
|
275
|
+
console.log(`Accessibility snapshot:\n${s.snapshot}`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Emit resized images from resizeImageForAgent() calls that aren't
|
|
280
|
+
// already emitted as part of labeled screenshots
|
|
281
|
+
if (kittyEnabled && result.images && result.images.length > 0) {
|
|
282
|
+
for (const img of result.images) {
|
|
283
|
+
if (img.data && !emittedImages.has(img.data)) {
|
|
284
|
+
emitKittyImage({ base64: img.data })
|
|
285
|
+
emittedImages.add(img.data)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
189
288
|
}
|
|
190
289
|
|
|
191
290
|
if (result.isError) {
|
|
@@ -204,13 +303,106 @@ async function executeCode(options: {
|
|
|
204
303
|
}
|
|
205
304
|
|
|
206
305
|
// Session management commands
|
|
306
|
+
// Unified browser option type used in the multi-browser selection table
|
|
307
|
+
interface BrowserOption {
|
|
308
|
+
key: string
|
|
309
|
+
type: 'extension' | 'direct'
|
|
310
|
+
browser: string
|
|
311
|
+
profile: string
|
|
312
|
+
/** For extension entries */
|
|
313
|
+
extensionId?: string | null
|
|
314
|
+
/** For direct CDP entries */
|
|
315
|
+
wsUrl?: string
|
|
316
|
+
/** Raw profile data from discovery (for passing to relay) */
|
|
317
|
+
profiles?: Array<{ name: string; email: string }>
|
|
318
|
+
}
|
|
319
|
+
|
|
207
320
|
cli
|
|
208
321
|
.command('session new', 'Create a new session and print the session ID')
|
|
209
322
|
.option('--host <host>', 'Remote relay server host')
|
|
210
|
-
.option('--browser <
|
|
211
|
-
.
|
|
323
|
+
.option('--browser <key>', 'Browser key when multiple browsers are available')
|
|
324
|
+
.option('--direct [endpoint]', 'Use direct CDP connection without the extension. Enable debugging first at chrome://inspect/#remote-debugging or launch Chrome with --remote-debugging-port=9222. Auto-discovers instances or accepts an explicit ws:// endpoint')
|
|
325
|
+
.action(async (options) => {
|
|
212
326
|
const isLocal = !options.host && !process.env.PLAYWRITER_HOST
|
|
327
|
+
const directEndpoint = typeof options.direct === 'string' ? options.direct : null
|
|
328
|
+
|
|
329
|
+
// If --direct with explicit endpoint, resolve it (handles host:port → ws://) then skip discovery
|
|
330
|
+
if (directEndpoint) {
|
|
331
|
+
let cdpEndpoint: string
|
|
332
|
+
try {
|
|
333
|
+
cdpEndpoint = await resolveDirectInput(directEndpoint)
|
|
334
|
+
} catch (error: any) {
|
|
335
|
+
console.error(`Error: ${error.message}`)
|
|
336
|
+
process.exit(1)
|
|
337
|
+
}
|
|
338
|
+
await ensureRelayForSessionCreation(isLocal)
|
|
339
|
+
const serverUrl = await getServerUrl(options.host)
|
|
340
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint })
|
|
341
|
+
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
342
|
+
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
343
|
+
return
|
|
344
|
+
}
|
|
213
345
|
|
|
346
|
+
// If --direct with no endpoint, discover Chrome instances
|
|
347
|
+
if (options.direct === true) {
|
|
348
|
+
if (!isLocal) {
|
|
349
|
+
console.error('Error: --direct auto-discovery only works locally.')
|
|
350
|
+
console.error('For remote relay, pass an explicit endpoint reachable from the relay host:')
|
|
351
|
+
console.error(' playwriter session new --host <host> --direct ws://relay-host:9222/devtools/browser/...')
|
|
352
|
+
process.exit(1)
|
|
353
|
+
}
|
|
354
|
+
await ensureRelayForSessionCreation(isLocal)
|
|
355
|
+
console.log(pc.dim('Discovering Chrome instances with debugging enabled...'))
|
|
356
|
+
const instances = await discoverChromeInstances()
|
|
357
|
+
|
|
358
|
+
if (instances.length === 0) {
|
|
359
|
+
console.error('No Chrome instances with debugging enabled found.')
|
|
360
|
+
console.error('')
|
|
361
|
+
console.error('Enable debugging in one of these ways:')
|
|
362
|
+
console.error(' 1. Open chrome://inspect/#remote-debugging in Chrome')
|
|
363
|
+
console.error(' 2. Launch Chrome with: chrome --remote-debugging-port=9222')
|
|
364
|
+
process.exit(1)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (instances.length === 1 && !options.browser) {
|
|
368
|
+
const instance = instances[0]
|
|
369
|
+
const serverUrl = await getServerUrl(options.host)
|
|
370
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles })
|
|
371
|
+
const profileLabel = formatInstanceProfiles(instance)
|
|
372
|
+
console.log(
|
|
373
|
+
`Session ${result.id} created (direct CDP, ${instance.browser}${profileLabel}). Use with: playwriter -s ${result.id} -e "..."`,
|
|
374
|
+
)
|
|
375
|
+
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Multiple instances or --browser specified
|
|
380
|
+
const directOptions = instances.map((instance) => {
|
|
381
|
+
return instanceToBrowserOption(instance)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
if (options.browser) {
|
|
385
|
+
const selected = directOptions.find((opt) => {
|
|
386
|
+
return opt.key === options.browser
|
|
387
|
+
})
|
|
388
|
+
if (!selected) {
|
|
389
|
+
console.error(`Browser not found: ${options.browser}`)
|
|
390
|
+
console.error('Available: ' + directOptions.map((opt) => opt.key).join(', '))
|
|
391
|
+
process.exit(1)
|
|
392
|
+
}
|
|
393
|
+
const serverUrl = await getServerUrl(options.host)
|
|
394
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
|
|
395
|
+
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
396
|
+
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
printBrowserTable(directOptions)
|
|
401
|
+
console.log('\nRun again with --browser <key>.')
|
|
402
|
+
process.exit(1)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Default mode: extension-based (existing behavior)
|
|
214
406
|
let extensions: ExtensionStatus[] = []
|
|
215
407
|
|
|
216
408
|
if (isLocal) {
|
|
@@ -235,6 +427,7 @@ cli
|
|
|
235
427
|
|
|
236
428
|
if (extensions.length === 0) {
|
|
237
429
|
console.error('No connected browsers detected. Click the Playwriter extension icon.')
|
|
430
|
+
console.error(pc.dim('Tip: Use --direct to connect via Chrome DevTools Protocol instead.'))
|
|
238
431
|
process.exit(1)
|
|
239
432
|
}
|
|
240
433
|
|
|
@@ -247,64 +440,184 @@ cli
|
|
|
247
440
|
}
|
|
248
441
|
}
|
|
249
442
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
443
|
+
// Single extension: auto-select (unchanged behavior)
|
|
444
|
+
if (extensions.length === 1 && !options.browser) {
|
|
445
|
+
const selectedExtension = extensions[0]
|
|
446
|
+
try {
|
|
447
|
+
const serverUrl = await getServerUrl(options.host)
|
|
448
|
+
const extensionId =
|
|
449
|
+
selectedExtension.extensionId === 'default'
|
|
450
|
+
? null
|
|
451
|
+
: selectedExtension.stableKey || selectedExtension.extensionId
|
|
452
|
+
const cwd = process.cwd()
|
|
453
|
+
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
454
|
+
method: 'POST',
|
|
455
|
+
headers: { 'Content-Type': 'application/json' },
|
|
456
|
+
body: JSON.stringify({ extensionId, cwd }),
|
|
457
|
+
})
|
|
458
|
+
if (!response.ok) {
|
|
459
|
+
const text = await response.text()
|
|
460
|
+
console.error(`Error: ${response.status} ${text}`)
|
|
461
|
+
process.exit(1)
|
|
462
|
+
}
|
|
463
|
+
const result = (await response.json()) as { id: string; extensionId: string | null }
|
|
464
|
+
console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
|
|
465
|
+
} catch (error: any) {
|
|
466
|
+
console.error(`Error: ${error.message}`)
|
|
270
467
|
process.exit(1)
|
|
271
468
|
}
|
|
469
|
+
return
|
|
272
470
|
}
|
|
273
471
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
:
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
472
|
+
// Multiple extensions: also discover direct CDP instances and show unified table.
|
|
473
|
+
// Only discover locally — remote relay can't reach local Chrome debug ports.
|
|
474
|
+
const directInstances = isLocal ? await (async () => {
|
|
475
|
+
console.log(pc.dim('Discovering additional Chrome instances...'))
|
|
476
|
+
return await discoverChromeInstances()
|
|
477
|
+
})() : []
|
|
478
|
+
|
|
479
|
+
const allOptions: BrowserOption[] = [
|
|
480
|
+
...extensions.map((ext) => {
|
|
481
|
+
return {
|
|
482
|
+
key: ext.stableKey || ext.extensionId,
|
|
483
|
+
type: 'extension' as const,
|
|
484
|
+
browser: ext.browser || 'Chrome',
|
|
485
|
+
profile: ext.profile?.email || '(not signed in)',
|
|
486
|
+
extensionId: ext.extensionId === 'default' ? null : ext.stableKey || ext.extensionId,
|
|
487
|
+
}
|
|
488
|
+
}),
|
|
489
|
+
...directInstances.map((instance) => {
|
|
490
|
+
return instanceToBrowserOption(instance)
|
|
491
|
+
}),
|
|
492
|
+
]
|
|
493
|
+
|
|
494
|
+
if (options.browser) {
|
|
495
|
+
const selected = allOptions.find((opt) => {
|
|
496
|
+
return opt.key === options.browser
|
|
290
497
|
})
|
|
291
|
-
if (!
|
|
292
|
-
|
|
293
|
-
console.error(
|
|
498
|
+
if (!selected) {
|
|
499
|
+
console.error(`Browser not found: ${options.browser}`)
|
|
500
|
+
console.error('Available: ' + allOptions.map((opt) => opt.key).join(', '))
|
|
294
501
|
process.exit(1)
|
|
295
502
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
const serverUrl = await getServerUrl(options.host)
|
|
506
|
+
if (selected.type === 'direct') {
|
|
507
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
|
|
508
|
+
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
509
|
+
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
510
|
+
} else {
|
|
511
|
+
const cwd = process.cwd()
|
|
512
|
+
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
513
|
+
method: 'POST',
|
|
514
|
+
headers: { 'Content-Type': 'application/json' },
|
|
515
|
+
body: JSON.stringify({ extensionId: selected.extensionId, cwd }),
|
|
516
|
+
})
|
|
517
|
+
if (!response.ok) {
|
|
518
|
+
const text = await response.text()
|
|
519
|
+
console.error(`Error: ${response.status} ${text}`)
|
|
520
|
+
process.exit(1)
|
|
521
|
+
}
|
|
522
|
+
const result = (await response.json()) as { id: string }
|
|
523
|
+
console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
|
|
524
|
+
}
|
|
525
|
+
} catch (error: any) {
|
|
526
|
+
console.error(`Error: ${error.message}`)
|
|
527
|
+
process.exit(1)
|
|
528
|
+
}
|
|
529
|
+
return
|
|
301
530
|
}
|
|
531
|
+
|
|
532
|
+
// Show unified table
|
|
533
|
+
console.log('\nMultiple browsers detected:\n')
|
|
534
|
+
printBrowserTable(allOptions)
|
|
535
|
+
console.log('\nRun again with --browser <key>.')
|
|
536
|
+
process.exit(1)
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
async function ensureRelayForSessionCreation(isLocal: boolean): Promise<void> {
|
|
540
|
+
if (isLocal) {
|
|
541
|
+
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function createDirectSession({
|
|
546
|
+
serverUrl,
|
|
547
|
+
cdpEndpoint,
|
|
548
|
+
browser,
|
|
549
|
+
profiles,
|
|
550
|
+
}: {
|
|
551
|
+
serverUrl: string
|
|
552
|
+
cdpEndpoint: string
|
|
553
|
+
browser?: string
|
|
554
|
+
profiles?: Array<{ name: string; email: string }>
|
|
555
|
+
}): Promise<{ id: string }> {
|
|
556
|
+
const cwd = process.cwd()
|
|
557
|
+
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
558
|
+
method: 'POST',
|
|
559
|
+
headers: { 'Content-Type': 'application/json' },
|
|
560
|
+
body: JSON.stringify({ cdpEndpoint, cwd, browser, profiles }),
|
|
561
|
+
})
|
|
562
|
+
if (!response.ok) {
|
|
563
|
+
const text = await response.text()
|
|
564
|
+
throw new Error(`${response.status} ${text}`)
|
|
565
|
+
}
|
|
566
|
+
return (await response.json()) as { id: string }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function instanceToBrowserOption(instance: DiscoveredInstance): BrowserOption {
|
|
570
|
+
return {
|
|
571
|
+
key: `direct:${instance.port}`,
|
|
572
|
+
type: 'direct',
|
|
573
|
+
browser: instance.browser,
|
|
574
|
+
profile: formatInstanceProfiles(instance),
|
|
575
|
+
wsUrl: instance.wsUrl,
|
|
576
|
+
profiles: instance.profiles,
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function formatInstanceProfiles(instance: DiscoveredInstance): string {
|
|
581
|
+
if (instance.profiles.length === 0) {
|
|
582
|
+
return '(unknown)'
|
|
583
|
+
}
|
|
584
|
+
return instance.profiles
|
|
585
|
+
.map((p) => {
|
|
586
|
+
return p.email ? `${p.name} (${p.email})` : p.name
|
|
587
|
+
})
|
|
588
|
+
.join(', ')
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function printBrowserTable(options: BrowserOption[]): void {
|
|
592
|
+
const typeLabels = options.map((opt) => {
|
|
593
|
+
return opt.type === 'direct' ? '--direct' : opt.type
|
|
302
594
|
})
|
|
595
|
+
const keyWidth = Math.max(3, ...options.map((opt) => opt.key.length))
|
|
596
|
+
const typeWidth = Math.max(4, ...typeLabels.map((t) => t.length))
|
|
597
|
+
const browserWidth = Math.max(7, ...options.map((opt) => opt.browser.length))
|
|
598
|
+
|
|
599
|
+
console.log(
|
|
600
|
+
'KEY'.padEnd(keyWidth) + ' ' + 'TYPE'.padEnd(typeWidth) + ' ' + 'BROWSER'.padEnd(browserWidth) + ' ' + 'PROFILE',
|
|
601
|
+
)
|
|
602
|
+
console.log('-'.repeat(keyWidth + typeWidth + browserWidth + 20))
|
|
603
|
+
for (let i = 0; i < options.length; i++) {
|
|
604
|
+
const opt = options[i]
|
|
605
|
+
console.log(
|
|
606
|
+
opt.key.padEnd(keyWidth) +
|
|
607
|
+
' ' +
|
|
608
|
+
typeLabels[i].padEnd(typeWidth) +
|
|
609
|
+
' ' +
|
|
610
|
+
opt.browser.padEnd(browserWidth) +
|
|
611
|
+
' ' +
|
|
612
|
+
opt.profile,
|
|
613
|
+
)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
303
616
|
|
|
304
617
|
cli
|
|
305
618
|
.command('session list', 'List all active sessions')
|
|
306
619
|
.option('--host <host>', 'Remote relay server host')
|
|
307
|
-
.action(async (options
|
|
620
|
+
.action(async (options) => {
|
|
308
621
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
|
309
622
|
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
|
310
623
|
}
|
|
@@ -316,6 +629,7 @@ cli
|
|
|
316
629
|
browser: string | null
|
|
317
630
|
profile: { email: string; id: string } | null
|
|
318
631
|
extensionId: string | null
|
|
632
|
+
cwd: string | null
|
|
319
633
|
}> = []
|
|
320
634
|
|
|
321
635
|
try {
|
|
@@ -333,6 +647,7 @@ cli
|
|
|
333
647
|
browser: string | null
|
|
334
648
|
profile: { email: string; id: string } | null
|
|
335
649
|
extensionId: string | null
|
|
650
|
+
cwd: string | null
|
|
336
651
|
}>
|
|
337
652
|
}
|
|
338
653
|
sessions = result.sessions
|
|
@@ -350,6 +665,7 @@ cli
|
|
|
350
665
|
const browserWidth = Math.max(7, ...sessions.map((session) => (session.browser || 'Chrome').length))
|
|
351
666
|
const profileWidth = Math.max(7, ...sessions.map((session) => (session.profile?.email || '').length || 1))
|
|
352
667
|
const extensionWidth = Math.max(2, ...sessions.map((session) => (session.extensionId || '').length || 1))
|
|
668
|
+
const cwdWidth = Math.max(3, ...sessions.map((session) => (session.cwd || '').length || 1))
|
|
353
669
|
const stateWidth = Math.max(10, ...sessions.map((session) => session.stateKeys.join(', ').length || 1))
|
|
354
670
|
|
|
355
671
|
console.log(
|
|
@@ -361,13 +677,16 @@ cli
|
|
|
361
677
|
' ' +
|
|
362
678
|
'EXT'.padEnd(extensionWidth) +
|
|
363
679
|
' ' +
|
|
680
|
+
'CWD'.padEnd(cwdWidth) +
|
|
681
|
+
' ' +
|
|
364
682
|
'STATE KEYS',
|
|
365
683
|
)
|
|
366
|
-
console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + stateWidth +
|
|
684
|
+
console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + cwdWidth + stateWidth + 10))
|
|
367
685
|
|
|
368
686
|
for (const session of sessions) {
|
|
369
687
|
const stateStr = session.stateKeys.length > 0 ? session.stateKeys.join(', ') : '-'
|
|
370
688
|
const profileLabel = session.profile?.email || '-'
|
|
689
|
+
const cwdLabel = session.cwd || '-'
|
|
371
690
|
console.log(
|
|
372
691
|
String(session.id).padEnd(idWidth) +
|
|
373
692
|
' ' +
|
|
@@ -377,6 +696,8 @@ cli
|
|
|
377
696
|
' ' +
|
|
378
697
|
(session.extensionId || '-').padEnd(extensionWidth) +
|
|
379
698
|
' ' +
|
|
699
|
+
cwdLabel.padEnd(cwdWidth) +
|
|
700
|
+
' ' +
|
|
380
701
|
stateStr,
|
|
381
702
|
)
|
|
382
703
|
}
|
|
@@ -385,7 +706,7 @@ cli
|
|
|
385
706
|
cli
|
|
386
707
|
.command('session delete <sessionId>', 'Delete a session and clear its state')
|
|
387
708
|
.option('--host <host>', 'Remote relay server host')
|
|
388
|
-
.action(async (sessionId
|
|
709
|
+
.action(async (sessionId, options) => {
|
|
389
710
|
const serverUrl = await getServerUrl(options.host)
|
|
390
711
|
|
|
391
712
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
|
@@ -415,7 +736,7 @@ cli
|
|
|
415
736
|
cli
|
|
416
737
|
.command('session reset <sessionId>', 'Reset the browser connection for a session')
|
|
417
738
|
.option('--host <host>', 'Remote relay server host')
|
|
418
|
-
.action(async (sessionId
|
|
739
|
+
.action(async (sessionId, options) => {
|
|
419
740
|
const cwd = process.cwd()
|
|
420
741
|
const serverUrl = await getServerUrl(options.host)
|
|
421
742
|
|
|
@@ -451,10 +772,10 @@ cli
|
|
|
451
772
|
'serve',
|
|
452
773
|
`Start the relay server on this machine (must be the same host where Chrome is running). Remote clients (Docker, other machines) connect via PLAYWRITER_HOST. Use --host localhost for Docker (no token needed) — containers reach it via host.docker.internal. Use --host 0.0.0.0 for LAN/internet access (requires --token).`,
|
|
453
774
|
)
|
|
454
|
-
.option('--host
|
|
775
|
+
.option('--host [host]', z.string().default('0.0.0.0').describe('Host to bind to (use "localhost" for Docker, "0.0.0.0" for remote access)'))
|
|
455
776
|
.option('--token <token>', 'Authentication token, required when --host is 0.0.0.0 (or use PLAYWRITER_TOKEN env var)')
|
|
456
777
|
.option('--replace', 'Kill existing server if running')
|
|
457
|
-
.action(async (options
|
|
778
|
+
.action(async (options) => {
|
|
458
779
|
const token = options.token || process.env.PLAYWRITER_TOKEN
|
|
459
780
|
const isPublicHost = options.host === '0.0.0.0' || options.host === '::'
|
|
460
781
|
if (isPublicHost && !token) {
|
|
@@ -543,6 +864,58 @@ cli
|
|
|
543
864
|
})
|
|
544
865
|
})
|
|
545
866
|
|
|
867
|
+
cli
|
|
868
|
+
.command('browser list', 'List all available browsers: extension-connected and direct CDP on port 9222')
|
|
869
|
+
.option('--host <host>', z.string().describe('Remote relay server host'))
|
|
870
|
+
.action(async (options) => {
|
|
871
|
+
const isLocal = !options.host && !process.env.PLAYWRITER_HOST
|
|
872
|
+
|
|
873
|
+
// Start relay if local so the extension can connect, then fetch in parallel
|
|
874
|
+
if (isLocal) {
|
|
875
|
+
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const [extensions, directInstances] = await Promise.all([
|
|
879
|
+
isLocal
|
|
880
|
+
? waitForConnectedExtensions({ timeoutMs: 2000, pollIntervalMs: 200, logger: console })
|
|
881
|
+
: fetchExtensionsStatus(options.host),
|
|
882
|
+
isLocal ? discoverChromeInstances() : Promise.resolve([] as DiscoveredInstance[]),
|
|
883
|
+
])
|
|
884
|
+
|
|
885
|
+
const allOptions: BrowserOption[] = [
|
|
886
|
+
...extensions.map((ext) => {
|
|
887
|
+
return {
|
|
888
|
+
key: ext.stableKey || ext.extensionId,
|
|
889
|
+
type: 'extension' as const,
|
|
890
|
+
browser: ext.browser || 'Chrome',
|
|
891
|
+
profile: ext.profile?.email || '(not signed in)',
|
|
892
|
+
extensionId: ext.extensionId === 'default' ? null : ext.stableKey || ext.extensionId,
|
|
893
|
+
}
|
|
894
|
+
}),
|
|
895
|
+
...directInstances.map(instanceToBrowserOption),
|
|
896
|
+
]
|
|
897
|
+
|
|
898
|
+
if (allOptions.length === 0) {
|
|
899
|
+
console.log('No browsers detected.\n')
|
|
900
|
+
console.log(' Extension: click the Playwriter icon on a tab to connect')
|
|
901
|
+
console.log(' Direct: open chrome://inspect/#remote-debugging in Chrome')
|
|
902
|
+
return
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
printBrowserTable(allOptions)
|
|
906
|
+
console.log('')
|
|
907
|
+
|
|
908
|
+
const hasDirectInstances = allOptions.some((opt) => {
|
|
909
|
+
return opt.type === 'direct'
|
|
910
|
+
})
|
|
911
|
+
if (hasDirectInstances) {
|
|
912
|
+
console.log(pc.dim('Connect with: playwriter session new --direct'))
|
|
913
|
+
console.log(pc.dim('Chrome may ask to approve the debugging connection.'))
|
|
914
|
+
} else {
|
|
915
|
+
console.log(pc.dim('Use with: playwriter session new [--browser <key>]'))
|
|
916
|
+
}
|
|
917
|
+
})
|
|
918
|
+
|
|
546
919
|
cli.command('logfile', 'Print the path to the relay server log file').action(() => {
|
|
547
920
|
console.log(`relay: ${LOG_FILE_PATH}`)
|
|
548
921
|
console.log(`cdp: ${LOG_CDP_FILE_PATH}`)
|