playwriter 0.0.105 → 0.2.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/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +17 -5
- package/dist/cdp-relay.js.map +1 -1
- package/dist/cli-help.test.d.ts +2 -0
- package/dist/cli-help.test.d.ts.map +1 -0
- package/dist/cli-help.test.js +53 -0
- package/dist/cli-help.test.js.map +1 -0
- package/dist/cli.js +74 -25
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts +1 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +55 -12
- package/dist/executor.js.map +1 -1
- package/dist/extension/background.js +675 -27
- package/dist/extension/manifest.json +1 -1
- package/dist/ghost-cursor-client.js +170 -83
- package/dist/{recording-ghost-cursor.d.ts → ghost-cursor-controller.d.ts} +15 -10
- package/dist/ghost-cursor-controller.d.ts.map +1 -0
- package/dist/ghost-cursor-controller.js +98 -0
- package/dist/ghost-cursor-controller.js.map +1 -0
- package/dist/ghost-cursor.d.ts.map +1 -1
- package/dist/ghost-cursor.js +42 -26
- package/dist/ghost-cursor.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +6 -1
- package/dist/mcp.js.map +1 -1
- package/dist/on-mouse-action.test.js +25 -0
- package/dist/on-mouse-action.test.js.map +1 -1
- package/dist/performance-examples.d.ts +5 -0
- package/dist/performance-examples.d.ts.map +1 -0
- package/dist/performance-examples.js +112 -0
- package/dist/performance-examples.js.map +1 -0
- package/dist/performance-profiling.md +417 -0
- package/dist/prompt.md +22 -8
- package/dist/react-source.d.ts +44 -0
- package/dist/react-source.d.ts.map +1 -1
- package/dist/react-source.js +207 -20
- package/dist/react-source.js.map +1 -1
- package/dist/readability.js +1 -1
- package/dist/relay-core.test.d.ts.map +1 -1
- package/dist/relay-core.test.js +101 -1
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-session.test.js +34 -6
- package/dist/relay-session.test.js.map +1 -1
- package/dist/screen-recording.d.ts +2 -2
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +19 -7
- package/dist/screen-recording.js.map +1 -1
- package/dist/selector-generator.js +1 -1
- package/package.json +7 -7
- package/src/aria-snapshots/github-interactive.txt +5 -3
- package/src/aria-snapshots/github-raw.txt +8 -5
- package/src/aria-snapshots/hackernews-interactive.txt +241 -238
- package/src/aria-snapshots/hackernews-raw.txt +269 -265
- package/src/aria-snapshots/prosemirror-interactive.txt +3 -1
- package/src/aria-snapshots/prosemirror-raw.txt +4 -1
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/assets/aria-labels-old-reddit.png +0 -0
- package/src/cdp-relay.ts +17 -5
- package/src/cli-help.test.ts +63 -0
- package/src/cli.ts +80 -28
- package/src/executor.ts +65 -15
- package/src/ghost-cursor-client.ts +221 -96
- package/src/{recording-ghost-cursor.ts → ghost-cursor-controller.ts} +50 -34
- package/src/ghost-cursor.ts +54 -41
- package/src/mcp.ts +6 -1
- package/src/on-mouse-action.test.ts +30 -0
- package/src/performance-examples.ts +186 -0
- package/src/react-source.ts +310 -24
- package/src/relay-core.test.ts +117 -0
- package/src/relay-session.test.ts +36 -10
- package/src/screen-recording.ts +23 -10
- package/src/skill.md +33 -9
- package/src/snapshots/shadcn-ui-accessibility-full.md +6 -3
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +2 -0
- package/dist/recording-ghost-cursor.d.ts.map +0 -1
- package/dist/recording-ghost-cursor.js +0 -79
- package/dist/recording-ghost-cursor.js.map +0 -1
|
@@ -60,4 +60,6 @@
|
|
|
60
60
|
- role=link[name="Backers"]
|
|
61
61
|
- role=link[name="Code of Conduct"]
|
|
62
62
|
- role=link[name="Discuss"] >> nth=1
|
|
63
|
-
- role=link[name="Report an Issue"]
|
|
63
|
+
- role=link[name="Report an Issue"]
|
|
64
|
+
- role=button[name="Pin element — click any element to copy its Playwriter reference"]
|
|
65
|
+
- role=button[name="Close Playwriter toolbar"]
|
|
@@ -138,4 +138,7 @@
|
|
|
138
138
|
- role=link[name="Backers"]
|
|
139
139
|
- role=link[name="Code of Conduct"]
|
|
140
140
|
- role=link[name="Discuss"] >> nth=1
|
|
141
|
-
- role=link[name="Report an Issue"]
|
|
141
|
+
- role=link[name="Report an Issue"]
|
|
142
|
+
- toolbar "Playwriter tools":
|
|
143
|
+
- role=button[name="Pin element — click any element to copy its Playwriter reference"]
|
|
144
|
+
- role=button[name="Close Playwriter toolbar"]
|
|
Binary file
|
|
Binary file
|
package/src/cdp-relay.ts
CHANGED
|
@@ -1755,7 +1755,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1755
1755
|
const { ExecutorManager } = await import('./executor.js')
|
|
1756
1756
|
// Pass config instead of URL so executor can generate unique client IDs for each connection
|
|
1757
1757
|
executorManager = new ExecutorManager({
|
|
1758
|
-
cdpConfig: { host: '127.0.0.1', port },
|
|
1758
|
+
cdpConfig: { host: '127.0.0.1', port, token },
|
|
1759
1759
|
logger: logger || { log: console.error, error: console.error },
|
|
1760
1760
|
})
|
|
1761
1761
|
}
|
|
@@ -1763,20 +1763,24 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1763
1763
|
}
|
|
1764
1764
|
|
|
1765
1765
|
// ============================================================================
|
|
1766
|
-
// Security middleware for privileged HTTP routes (/cli/*, /recording
|
|
1766
|
+
// Security middleware for privileged HTTP routes (/cli/*, /recording/*, /mcp-log)
|
|
1767
1767
|
//
|
|
1768
1768
|
// CORS alone does NOT prevent cross-origin POST attacks. Browsers skip the
|
|
1769
1769
|
// preflight for "simple" requests (POST + Content-Type: text/plain), so a
|
|
1770
1770
|
// malicious website can fire-and-forget a POST to localhost:19988/cli/execute
|
|
1771
1771
|
// and the code executes before CORS even enters the picture.
|
|
1772
1772
|
//
|
|
1773
|
-
//
|
|
1773
|
+
// Three layers of defense:
|
|
1774
1774
|
// 1. Sec-Fetch-Site: browsers set this forbidden header on every request.
|
|
1775
1775
|
// If present and not "same-origin"/"none", it's a cross-origin browser
|
|
1776
1776
|
// request → reject. Node.js clients don't send it → unaffected.
|
|
1777
1777
|
// 2. Content-Type must be application/json on POST. This forces a CORS
|
|
1778
1778
|
// preflight as a fallback, which our CORS policy already blocks.
|
|
1779
|
-
// 3. When token mode is enabled (remote access), require the token
|
|
1779
|
+
// 3. When token mode is enabled (remote access), require the token on EVERY
|
|
1780
|
+
// request, including loopback. Tunnel agents (traforo, ngrok, cloudflared)
|
|
1781
|
+
// forward public traffic from 127.0.0.1, so a loopback bypass would be
|
|
1782
|
+
// a full auth bypass. In-process callers attach the token themselves
|
|
1783
|
+
// via PLAYWRITER_TOKEN env (set by the `serve` command at startup).
|
|
1780
1784
|
// ============================================================================
|
|
1781
1785
|
const privilegedRouteMiddleware = async (
|
|
1782
1786
|
c: Parameters<Parameters<typeof app.use>[1]>[0],
|
|
@@ -1801,7 +1805,14 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1801
1805
|
}
|
|
1802
1806
|
}
|
|
1803
1807
|
|
|
1804
|
-
// When token mode is enabled (remote/serve mode), require authentication
|
|
1808
|
+
// When token mode is enabled (remote/serve mode), require authentication
|
|
1809
|
+
// on EVERY request, including loopback. Earlier versions bypassed the
|
|
1810
|
+
// check for 127.0.0.1/::1 to spare in-process callers, but that's unsafe:
|
|
1811
|
+
// when the relay is fronted by a tunnel agent (traforo, ngrok, cloudflared,
|
|
1812
|
+
// etc.) running as a local process, every public request reaches the relay
|
|
1813
|
+
// from 127.0.0.1 and would skip auth. In-process callers must instead
|
|
1814
|
+
// attach the token themselves — they read PLAYWRITER_TOKEN from env, which
|
|
1815
|
+
// the `serve` command sets at startup.
|
|
1805
1816
|
if (token) {
|
|
1806
1817
|
const authHeader = c.req.header('authorization') || ''
|
|
1807
1818
|
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null
|
|
@@ -1818,6 +1829,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1818
1829
|
|
|
1819
1830
|
app.use('/cli/*', privilegedRouteMiddleware)
|
|
1820
1831
|
app.use('/recording/*', privilegedRouteMiddleware)
|
|
1832
|
+
app.use('/mcp-log', privilegedRouteMiddleware)
|
|
1821
1833
|
|
|
1822
1834
|
app.post('/cli/execute', async (c) => {
|
|
1823
1835
|
try {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Verifies CLI help stays runnable without loading browser-start-only dependencies.
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
import { describe, expect, test } from 'vitest'
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile)
|
|
9
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const playwriterDir = path.resolve(currentDir, '..')
|
|
11
|
+
const viteNodeBinary = path.join(
|
|
12
|
+
playwriterDir,
|
|
13
|
+
'node_modules',
|
|
14
|
+
'.bin',
|
|
15
|
+
process.platform === 'win32' ? 'vite-node.cmd' : 'vite-node',
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
async function runCli(args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
19
|
+
return execFileAsync(viteNodeBinary, ['src/cli.ts', ...args], {
|
|
20
|
+
cwd: playwriterDir,
|
|
21
|
+
env: process.env,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('playwriter cli help', () => {
|
|
26
|
+
test('renders root help without crashing', async () => {
|
|
27
|
+
const { stdout, stderr } = await runCli(['--help'])
|
|
28
|
+
|
|
29
|
+
expect(stdout).toContain('playwriter')
|
|
30
|
+
expect(stdout).toContain('serve')
|
|
31
|
+
expect(stderr).toBe('')
|
|
32
|
+
}, 30000)
|
|
33
|
+
|
|
34
|
+
test('renders serve help without crashing', async () => {
|
|
35
|
+
const { stdout, stderr } = await runCli(['serve', '--help'])
|
|
36
|
+
|
|
37
|
+
expect(stdout).toContain('Start the relay server on this machine')
|
|
38
|
+
expect(stdout).toContain('--replace')
|
|
39
|
+
expect(stderr).toBe('')
|
|
40
|
+
}, 30000)
|
|
41
|
+
|
|
42
|
+
test('unknown command exits with code 1', async () => {
|
|
43
|
+
try {
|
|
44
|
+
await runCli(['run'])
|
|
45
|
+
expect.unreachable('should have thrown')
|
|
46
|
+
} catch (error: any) {
|
|
47
|
+
expect(error.code).toBe(1)
|
|
48
|
+
expect(error.stderr).toContain('Unknown command: run')
|
|
49
|
+
expect(error.stderr).toContain('playwriter --help')
|
|
50
|
+
}
|
|
51
|
+
}, 30000)
|
|
52
|
+
|
|
53
|
+
test('unknown subcommand exits with code 1', async () => {
|
|
54
|
+
try {
|
|
55
|
+
await runCli(['session', 'nonexistent'])
|
|
56
|
+
expect.unreachable('should have thrown')
|
|
57
|
+
} catch (error: any) {
|
|
58
|
+
expect(error.code).toBe(1)
|
|
59
|
+
expect(error.stdout).toContain('Unknown command: session nonexistent')
|
|
60
|
+
expect(error.stdout).toContain('session new')
|
|
61
|
+
}
|
|
62
|
+
}, 30000)
|
|
63
|
+
})
|
package/src/cli.ts
CHANGED
|
@@ -7,13 +7,6 @@ import { fileURLToPath } from 'node:url'
|
|
|
7
7
|
import { goke } from 'goke'
|
|
8
8
|
import { z } from 'zod'
|
|
9
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'
|
|
17
10
|
|
|
18
11
|
// Prevent Buffers from dumping hex bytes in util.inspect output.
|
|
19
12
|
Buffer.prototype[util.inspect.custom] = function () {
|
|
@@ -52,6 +45,14 @@ cli
|
|
|
52
45
|
}
|
|
53
46
|
|
|
54
47
|
try {
|
|
48
|
+
// Avoid loading playwright-core during generic CLI startup/help. This command
|
|
49
|
+
// is the only path that needs browser discovery and bundled extension launch.
|
|
50
|
+
const [{ getBrowserLaunchArgs, getDefaultBrowserUserDataDir, startBrowserProcess }, { resolveBrowserExecutablePath, shouldUseHeadlessByDefault }, { getBundledExtensionPath }] = await Promise.all([
|
|
51
|
+
import('./browser-launch.js'),
|
|
52
|
+
import('./browser-config.js'),
|
|
53
|
+
import('./package-paths.js'),
|
|
54
|
+
])
|
|
55
|
+
|
|
55
56
|
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
|
56
57
|
|
|
57
58
|
const browserPath = resolveBrowserExecutablePath({ browserPath: binaryPath })
|
|
@@ -104,12 +105,33 @@ cli
|
|
|
104
105
|
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
105
106
|
.option('-s, --session <name>', 'Session ID (required for -e, get one with `playwriter session new`)')
|
|
106
107
|
.option('-e, --eval <code>', 'Execute JavaScript code and exit, read https://playwriter.dev/SKILL.md for usage')
|
|
108
|
+
.option('-f, --file <path>', 'Execute JavaScript from a file and exit')
|
|
107
109
|
.option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
|
|
108
110
|
.action(async (options) => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
if (options.eval && options.file) {
|
|
112
|
+
console.error('Error: -e and -f cannot be used together.')
|
|
113
|
+
process.exit(1)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If -e or -f flag is provided, execute code via relay server
|
|
117
|
+
const code = (() => {
|
|
118
|
+
if (options.eval) {
|
|
119
|
+
return options.eval
|
|
120
|
+
}
|
|
121
|
+
if (options.file) {
|
|
122
|
+
const filePath = path.resolve(options.file)
|
|
123
|
+
if (!fs.existsSync(filePath)) {
|
|
124
|
+
console.error(`Error: File not found: ${filePath}`)
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
return fs.readFileSync(filePath, 'utf-8')
|
|
128
|
+
}
|
|
129
|
+
return null
|
|
130
|
+
})()
|
|
131
|
+
|
|
132
|
+
if (code) {
|
|
111
133
|
await executeCode({
|
|
112
|
-
code
|
|
134
|
+
code,
|
|
113
135
|
timeout: options.timeout || 10000,
|
|
114
136
|
sessionId: options.session,
|
|
115
137
|
host: options.host,
|
|
@@ -133,6 +155,20 @@ async function getServerUrl(host?: string): Promise<string> {
|
|
|
133
155
|
return httpBaseUrl
|
|
134
156
|
}
|
|
135
157
|
|
|
158
|
+
// Centralized header builder so every CLI subcommand sends the token consistently.
|
|
159
|
+
// Falls back to PLAYWRITER_TOKEN env var when --token is not provided.
|
|
160
|
+
function buildAuthHeaders({ token, json }: { token?: string; json?: boolean }): Record<string, string> {
|
|
161
|
+
const headers: Record<string, string> = {}
|
|
162
|
+
if (json) {
|
|
163
|
+
headers['Content-Type'] = 'application/json'
|
|
164
|
+
}
|
|
165
|
+
const effectiveToken = token || process.env.PLAYWRITER_TOKEN
|
|
166
|
+
if (effectiveToken) {
|
|
167
|
+
headers['Authorization'] = `Bearer ${effectiveToken}`
|
|
168
|
+
}
|
|
169
|
+
return headers
|
|
170
|
+
}
|
|
171
|
+
|
|
136
172
|
async function fetchExtensionsStatus(host?: string): Promise<ExtensionStatus[]> {
|
|
137
173
|
try {
|
|
138
174
|
const serverUrl = await getServerUrl(host)
|
|
@@ -224,12 +260,7 @@ async function executeCode(options: {
|
|
|
224
260
|
try {
|
|
225
261
|
const response = await fetch(executeUrl, {
|
|
226
262
|
method: 'POST',
|
|
227
|
-
headers: {
|
|
228
|
-
'Content-Type': 'application/json',
|
|
229
|
-
...(token || process.env.PLAYWRITER_TOKEN
|
|
230
|
-
? { Authorization: `Bearer ${token || process.env.PLAYWRITER_TOKEN}` }
|
|
231
|
-
: {}),
|
|
232
|
-
},
|
|
263
|
+
headers: buildAuthHeaders({ token, json: true }),
|
|
233
264
|
body: JSON.stringify({ sessionId, code, timeout, cwd }),
|
|
234
265
|
})
|
|
235
266
|
|
|
@@ -320,11 +351,16 @@ interface BrowserOption {
|
|
|
320
351
|
cli
|
|
321
352
|
.command('session new', 'Create a new session and print the session ID')
|
|
322
353
|
.option('--host <host>', 'Remote relay server host')
|
|
354
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
323
355
|
.option('--browser <key>', 'Browser key when multiple browsers are available')
|
|
324
356
|
.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
357
|
.action(async (options) => {
|
|
326
358
|
const isLocal = !options.host && !process.env.PLAYWRITER_HOST
|
|
327
|
-
|
|
359
|
+
// goke 6.6: optional-value flags are string | undefined
|
|
360
|
+
// `--direct ws://...` → 'ws://...' (explicit endpoint)
|
|
361
|
+
// `--direct` → '' (bare flag, auto-discover)
|
|
362
|
+
// (omitted) → undefined (don't use direct CDP)
|
|
363
|
+
const directEndpoint = options.direct || null
|
|
328
364
|
|
|
329
365
|
// If --direct with explicit endpoint, resolve it (handles host:port → ws://) then skip discovery
|
|
330
366
|
if (directEndpoint) {
|
|
@@ -337,14 +373,14 @@ cli
|
|
|
337
373
|
}
|
|
338
374
|
await ensureRelayForSessionCreation(isLocal)
|
|
339
375
|
const serverUrl = await getServerUrl(options.host)
|
|
340
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint })
|
|
376
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint, token: options.token })
|
|
341
377
|
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
342
378
|
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
343
379
|
return
|
|
344
380
|
}
|
|
345
381
|
|
|
346
382
|
// If --direct with no endpoint, discover Chrome instances
|
|
347
|
-
if (options.direct ===
|
|
383
|
+
if (options.direct === '') {
|
|
348
384
|
if (!isLocal) {
|
|
349
385
|
console.error('Error: --direct auto-discovery only works locally.')
|
|
350
386
|
console.error('For remote relay, pass an explicit endpoint reachable from the relay host:')
|
|
@@ -367,7 +403,7 @@ cli
|
|
|
367
403
|
if (instances.length === 1 && !options.browser) {
|
|
368
404
|
const instance = instances[0]
|
|
369
405
|
const serverUrl = await getServerUrl(options.host)
|
|
370
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles })
|
|
406
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles, token: options.token })
|
|
371
407
|
const profileLabel = formatInstanceProfiles(instance)
|
|
372
408
|
console.log(
|
|
373
409
|
`Session ${result.id} created (direct CDP, ${instance.browser}${profileLabel}). Use with: playwriter -s ${result.id} -e "..."`,
|
|
@@ -391,7 +427,7 @@ cli
|
|
|
391
427
|
process.exit(1)
|
|
392
428
|
}
|
|
393
429
|
const serverUrl = await getServerUrl(options.host)
|
|
394
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
|
|
430
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
|
|
395
431
|
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
396
432
|
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
397
433
|
return
|
|
@@ -452,7 +488,7 @@ cli
|
|
|
452
488
|
const cwd = process.cwd()
|
|
453
489
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
454
490
|
method: 'POST',
|
|
455
|
-
headers: {
|
|
491
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
456
492
|
body: JSON.stringify({ extensionId, cwd }),
|
|
457
493
|
})
|
|
458
494
|
if (!response.ok) {
|
|
@@ -504,14 +540,14 @@ cli
|
|
|
504
540
|
try {
|
|
505
541
|
const serverUrl = await getServerUrl(options.host)
|
|
506
542
|
if (selected.type === 'direct') {
|
|
507
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
|
|
543
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
|
|
508
544
|
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
509
545
|
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
510
546
|
} else {
|
|
511
547
|
const cwd = process.cwd()
|
|
512
548
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
513
549
|
method: 'POST',
|
|
514
|
-
headers: {
|
|
550
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
515
551
|
body: JSON.stringify({ extensionId: selected.extensionId, cwd }),
|
|
516
552
|
})
|
|
517
553
|
if (!response.ok) {
|
|
@@ -547,16 +583,18 @@ async function createDirectSession({
|
|
|
547
583
|
cdpEndpoint,
|
|
548
584
|
browser,
|
|
549
585
|
profiles,
|
|
586
|
+
token,
|
|
550
587
|
}: {
|
|
551
588
|
serverUrl: string
|
|
552
589
|
cdpEndpoint: string
|
|
553
590
|
browser?: string
|
|
554
591
|
profiles?: Array<{ name: string; email: string }>
|
|
592
|
+
token?: string
|
|
555
593
|
}): Promise<{ id: string }> {
|
|
556
594
|
const cwd = process.cwd()
|
|
557
595
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
558
596
|
method: 'POST',
|
|
559
|
-
headers: {
|
|
597
|
+
headers: buildAuthHeaders({ token, json: true }),
|
|
560
598
|
body: JSON.stringify({ cdpEndpoint, cwd, browser, profiles }),
|
|
561
599
|
})
|
|
562
600
|
if (!response.ok) {
|
|
@@ -617,6 +655,7 @@ function printBrowserTable(options: BrowserOption[]): void {
|
|
|
617
655
|
cli
|
|
618
656
|
.command('session list', 'List all active sessions')
|
|
619
657
|
.option('--host <host>', 'Remote relay server host')
|
|
658
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
620
659
|
.action(async (options) => {
|
|
621
660
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
|
622
661
|
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
|
@@ -634,6 +673,7 @@ cli
|
|
|
634
673
|
|
|
635
674
|
try {
|
|
636
675
|
const response = await fetch(`${serverUrl}/cli/sessions`, {
|
|
676
|
+
headers: buildAuthHeaders({ token: options.token }),
|
|
637
677
|
signal: AbortSignal.timeout(2000),
|
|
638
678
|
})
|
|
639
679
|
if (!response.ok) {
|
|
@@ -706,6 +746,7 @@ cli
|
|
|
706
746
|
cli
|
|
707
747
|
.command('session delete <sessionId>', 'Delete a session and clear its state')
|
|
708
748
|
.option('--host <host>', 'Remote relay server host')
|
|
749
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
709
750
|
.action(async (sessionId, options) => {
|
|
710
751
|
const serverUrl = await getServerUrl(options.host)
|
|
711
752
|
|
|
@@ -716,7 +757,7 @@ cli
|
|
|
716
757
|
try {
|
|
717
758
|
const response = await fetch(`${serverUrl}/cli/session/delete`, {
|
|
718
759
|
method: 'POST',
|
|
719
|
-
headers: {
|
|
760
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
720
761
|
body: JSON.stringify({ sessionId }),
|
|
721
762
|
})
|
|
722
763
|
|
|
@@ -736,6 +777,7 @@ cli
|
|
|
736
777
|
cli
|
|
737
778
|
.command('session reset <sessionId>', 'Reset the browser connection for a session')
|
|
738
779
|
.option('--host <host>', 'Remote relay server host')
|
|
780
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
739
781
|
.action(async (sessionId, options) => {
|
|
740
782
|
const cwd = process.cwd()
|
|
741
783
|
const serverUrl = await getServerUrl(options.host)
|
|
@@ -747,7 +789,7 @@ cli
|
|
|
747
789
|
try {
|
|
748
790
|
const response = await fetch(`${serverUrl}/cli/reset`, {
|
|
749
791
|
method: 'POST',
|
|
750
|
-
headers: {
|
|
792
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
751
793
|
body: JSON.stringify({ sessionId, cwd }),
|
|
752
794
|
})
|
|
753
795
|
|
|
@@ -784,6 +826,14 @@ cli
|
|
|
784
826
|
process.exit(1)
|
|
785
827
|
}
|
|
786
828
|
|
|
829
|
+
// Expose the token to in-process callers (screen-recording.ts, etc.) so
|
|
830
|
+
// they can attach Authorization: Bearer ... when calling the relay's own
|
|
831
|
+
// privileged endpoints. Required because we no longer bypass auth for
|
|
832
|
+
// loopback — see commit history for the tunnel-agent threat model.
|
|
833
|
+
if (token) {
|
|
834
|
+
process.env.PLAYWRITER_TOKEN = token
|
|
835
|
+
}
|
|
836
|
+
|
|
787
837
|
// Check if server is already running on the port
|
|
788
838
|
const net = await import('node:net')
|
|
789
839
|
const isPortInUse = await new Promise<boolean>((resolve) => {
|
|
@@ -867,6 +917,7 @@ cli
|
|
|
867
917
|
cli
|
|
868
918
|
.command('browser list', 'List all available browsers: extension-connected and direct CDP on port 9222')
|
|
869
919
|
.option('--host <host>', z.string().describe('Remote relay server host'))
|
|
920
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
870
921
|
.action(async (options) => {
|
|
871
922
|
const isLocal = !options.host && !process.env.PLAYWRITER_HOST
|
|
872
923
|
|
|
@@ -928,6 +979,7 @@ cli.command('skill', 'Print the full playwriter usage instructions').action(() =
|
|
|
928
979
|
})
|
|
929
980
|
|
|
930
981
|
cli.help()
|
|
982
|
+
cli.completions()
|
|
931
983
|
cli.version(VERSION)
|
|
932
984
|
|
|
933
|
-
cli.parse()
|
|
985
|
+
await cli.parse()
|
package/src/executor.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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, chromium, Locator, FrameLocator } from '@xmorse/playwright-core'
|
|
6
|
+
import { Page, Frame, Browser, BrowserContext, chromium, Locator, FrameLocator, ElementHandle } from '@xmorse/playwright-core'
|
|
7
7
|
import crypto from 'node:crypto'
|
|
8
8
|
import fs from 'node:fs'
|
|
9
9
|
import path from 'node:path'
|
|
@@ -21,7 +21,7 @@ import { ICDPSession, getCDPSessionForPage } from './cdp-session.js'
|
|
|
21
21
|
import { Debugger } from './debugger.js'
|
|
22
22
|
import { Editor } from './editor.js'
|
|
23
23
|
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
|
|
24
|
-
import { getReactSource, type ReactSourceLocation } from './react-source.js'
|
|
24
|
+
import { getReactSource, getReactComponentInfo, type ReactSourceLocation } from './react-source.js'
|
|
25
25
|
import { ScopedFS } from './scoped-fs.js'
|
|
26
26
|
import {
|
|
27
27
|
screenshotWithAccessibilityLabels,
|
|
@@ -37,7 +37,7 @@ import { getPageMarkdown, type GetPageMarkdownOptions } from './page-markdown.js
|
|
|
37
37
|
import { createRecordingApi } from './screen-recording.js'
|
|
38
38
|
import { createDemoVideo } from './ffmpeg.js'
|
|
39
39
|
import { type GhostCursorClientOptions } from './ghost-cursor.js'
|
|
40
|
-
import {
|
|
40
|
+
import { GhostCursorController } from './ghost-cursor-controller.js'
|
|
41
41
|
|
|
42
42
|
const __filename = fileURLToPath(import.meta.url)
|
|
43
43
|
const __dirname = path.dirname(__filename)
|
|
@@ -104,14 +104,14 @@ export function getAutoReturnExpression(code: string): string | null {
|
|
|
104
104
|
if (
|
|
105
105
|
expr.type === 'AssignmentExpression' ||
|
|
106
106
|
expr.type === 'UpdateExpression' ||
|
|
107
|
-
(expr.type === 'UnaryExpression' &&
|
|
107
|
+
(expr.type === 'UnaryExpression' && expr.operator === 'delete')
|
|
108
108
|
) {
|
|
109
109
|
return null
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
// Don't auto-return sequence expressions that contain assignments
|
|
113
113
|
if (expr.type === 'SequenceExpression') {
|
|
114
|
-
const hasAssignment = expr.expressions.some((e
|
|
114
|
+
const hasAssignment = expr.expressions.some((e) => e.type === 'AssignmentExpression')
|
|
115
115
|
if (hasAssignment) {
|
|
116
116
|
return null
|
|
117
117
|
}
|
|
@@ -312,6 +312,8 @@ export class PlaywrightExecutor {
|
|
|
312
312
|
private sessionCwd: string | null
|
|
313
313
|
private hasWarnedExtensionOutdated = false
|
|
314
314
|
|
|
315
|
+
private ghostCursorController: GhostCursorController
|
|
316
|
+
|
|
315
317
|
constructor(options: ExecutorOptions) {
|
|
316
318
|
this.cdpConfig = options.cdpConfig
|
|
317
319
|
this.logger = options.logger || { log: console.log, error: console.error }
|
|
@@ -323,6 +325,13 @@ export class PlaywrightExecutor {
|
|
|
323
325
|
this.sessionCwd || undefined,
|
|
324
326
|
)
|
|
325
327
|
this.sandboxedRequire = this.createSandboxedRequire(require)
|
|
328
|
+
this.ghostCursorController = new GhostCursorController({
|
|
329
|
+
logger: {
|
|
330
|
+
error: (...args: unknown[]) => {
|
|
331
|
+
this.logger.error(...args)
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
})
|
|
326
335
|
}
|
|
327
336
|
|
|
328
337
|
private createSandboxedRequire(originalRequire: NodeRequire): NodeRequire {
|
|
@@ -437,6 +446,10 @@ export class PlaywrightExecutor {
|
|
|
437
446
|
this.setupPageCloseDetection(page)
|
|
438
447
|
this.setupPageConsoleListener(page)
|
|
439
448
|
this.setupNewPageLogging(page)
|
|
449
|
+
this.ghostCursorController.attachToPage({ page })
|
|
450
|
+
page.on('close', () => {
|
|
451
|
+
this.ghostCursorController.detachFromPage({ page })
|
|
452
|
+
})
|
|
440
453
|
}
|
|
441
454
|
|
|
442
455
|
private setupPageCloseDetection(page: Page) {
|
|
@@ -1030,6 +1043,47 @@ export class PlaywrightExecutor {
|
|
|
1030
1043
|
return getReactSource({ locator: options.locator, cdp })
|
|
1031
1044
|
}
|
|
1032
1045
|
|
|
1046
|
+
const getReactComponentInfoFn = async (options: { locator: Locator | ElementHandle }) => {
|
|
1047
|
+
const targetPage = await (async (): Promise<Page | null> => {
|
|
1048
|
+
if ('page' in options.locator) {
|
|
1049
|
+
return options.locator.page()
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
return (await options.locator.ownerFrame())?.page() ?? null
|
|
1053
|
+
})()
|
|
1054
|
+
if (!targetPage) {
|
|
1055
|
+
throw new Error('Could not get page from locator')
|
|
1056
|
+
}
|
|
1057
|
+
const cdp = await getCDPSession({ page: targetPage })
|
|
1058
|
+
return getReactComponentInfo({ locator: options.locator, cdp })
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const inspectPinnedElement = async (pageUrl: string, elementExpression: string) => {
|
|
1062
|
+
const targetPage = context.pages().find((candidate) => candidate.url() === pageUrl) || context.pages()[0]
|
|
1063
|
+
if (!targetPage) {
|
|
1064
|
+
throw new Error('No Playwright pages are available')
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
this.userState.page = targetPage
|
|
1068
|
+
const handle = (await targetPage.evaluateHandle((expression) => {
|
|
1069
|
+
return Function(`return (${expression})`)()
|
|
1070
|
+
}, elementExpression)).asElement()
|
|
1071
|
+
|
|
1072
|
+
const result = await (async () => {
|
|
1073
|
+
if (!handle) {
|
|
1074
|
+
return { url: targetPage.url(), outerHTML: null, react: null }
|
|
1075
|
+
}
|
|
1076
|
+
return {
|
|
1077
|
+
url: targetPage.url(),
|
|
1078
|
+
outerHTML: await handle.evaluate((el) => el.outerHTML),
|
|
1079
|
+
react: await getReactComponentInfoFn({ locator: handle }),
|
|
1080
|
+
}
|
|
1081
|
+
})()
|
|
1082
|
+
|
|
1083
|
+
console.log(result)
|
|
1084
|
+
return result
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1033
1087
|
const screenshotCollector: ScreenshotResult[] = []
|
|
1034
1088
|
// Separate collector for images produced by resizeImageForAgent() calls.
|
|
1035
1089
|
// These get merged into result.images so the CLI can emit them via Kitty Graphics.
|
|
@@ -1061,13 +1115,7 @@ export class PlaywrightExecutor {
|
|
|
1061
1115
|
// This permission is granted when the user clicks the Playwriter extension icon on a tab.
|
|
1062
1116
|
const relayPort = this.cdpConfig.port || 19988
|
|
1063
1117
|
const self = this
|
|
1064
|
-
const
|
|
1065
|
-
logger: {
|
|
1066
|
-
error: (...args: unknown[]) => {
|
|
1067
|
-
self.logger.error(...args)
|
|
1068
|
-
},
|
|
1069
|
-
},
|
|
1070
|
-
})
|
|
1118
|
+
const ghostCursorController = this.ghostCursorController
|
|
1071
1119
|
|
|
1072
1120
|
const showGhostCursor = async (options?: ({ page?: Page } & GhostCursorClientOptions)) => {
|
|
1073
1121
|
const targetPage = options?.page || page
|
|
@@ -1080,19 +1128,19 @@ export class PlaywrightExecutor {
|
|
|
1080
1128
|
return rest
|
|
1081
1129
|
})()
|
|
1082
1130
|
|
|
1083
|
-
await
|
|
1131
|
+
await ghostCursorController.show({ page: targetPage, cursorOptions })
|
|
1084
1132
|
}
|
|
1085
1133
|
|
|
1086
1134
|
const hideGhostCursor = async (options?: { page?: Page }) => {
|
|
1087
1135
|
const targetPage = options?.page || page
|
|
1088
|
-
await
|
|
1136
|
+
await ghostCursorController.hide({ page: targetPage })
|
|
1089
1137
|
}
|
|
1090
1138
|
|
|
1091
1139
|
const recordingApi = createRecordingApi({
|
|
1092
1140
|
context,
|
|
1093
1141
|
defaultPage: page,
|
|
1094
1142
|
relayPort,
|
|
1095
|
-
ghostCursorController
|
|
1143
|
+
ghostCursorController,
|
|
1096
1144
|
onStart: () => {
|
|
1097
1145
|
self.recordingStartedAt = Date.now()
|
|
1098
1146
|
self.executionTimestamps = []
|
|
@@ -1139,6 +1187,8 @@ export class PlaywrightExecutor {
|
|
|
1139
1187
|
getStylesForLocator: getStylesForLocatorFn,
|
|
1140
1188
|
formatStylesAsText,
|
|
1141
1189
|
getReactSource: getReactSourceFn,
|
|
1190
|
+
getReactComponentInfo: getReactComponentInfoFn,
|
|
1191
|
+
inspectPinnedElement,
|
|
1142
1192
|
screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
|
|
1143
1193
|
resizeImageForAgent: resizeImageForAgentFn,
|
|
1144
1194
|
// Backward-compatible alias for resizeImageForAgent
|