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.
Files changed (79) hide show
  1. package/dist/bippy.js +5 -5
  2. package/dist/cdp-relay.d.ts.map +1 -1
  3. package/dist/cdp-relay.js +17 -5
  4. package/dist/cdp-relay.js.map +1 -1
  5. package/dist/cli-help.test.d.ts +2 -0
  6. package/dist/cli-help.test.d.ts.map +1 -0
  7. package/dist/cli-help.test.js +53 -0
  8. package/dist/cli-help.test.js.map +1 -0
  9. package/dist/cli.js +74 -25
  10. package/dist/cli.js.map +1 -1
  11. package/dist/executor.d.ts +1 -0
  12. package/dist/executor.d.ts.map +1 -1
  13. package/dist/executor.js +55 -12
  14. package/dist/executor.js.map +1 -1
  15. package/dist/extension/background.js +675 -27
  16. package/dist/extension/manifest.json +1 -1
  17. package/dist/ghost-cursor-client.js +170 -83
  18. package/dist/{recording-ghost-cursor.d.ts → ghost-cursor-controller.d.ts} +15 -10
  19. package/dist/ghost-cursor-controller.d.ts.map +1 -0
  20. package/dist/ghost-cursor-controller.js +98 -0
  21. package/dist/ghost-cursor-controller.js.map +1 -0
  22. package/dist/ghost-cursor.d.ts.map +1 -1
  23. package/dist/ghost-cursor.js +42 -26
  24. package/dist/ghost-cursor.js.map +1 -1
  25. package/dist/mcp.d.ts.map +1 -1
  26. package/dist/mcp.js +6 -1
  27. package/dist/mcp.js.map +1 -1
  28. package/dist/on-mouse-action.test.js +25 -0
  29. package/dist/on-mouse-action.test.js.map +1 -1
  30. package/dist/performance-examples.d.ts +5 -0
  31. package/dist/performance-examples.d.ts.map +1 -0
  32. package/dist/performance-examples.js +112 -0
  33. package/dist/performance-examples.js.map +1 -0
  34. package/dist/performance-profiling.md +417 -0
  35. package/dist/prompt.md +22 -8
  36. package/dist/react-source.d.ts +44 -0
  37. package/dist/react-source.d.ts.map +1 -1
  38. package/dist/react-source.js +207 -20
  39. package/dist/react-source.js.map +1 -1
  40. package/dist/readability.js +1 -1
  41. package/dist/relay-core.test.d.ts.map +1 -1
  42. package/dist/relay-core.test.js +101 -1
  43. package/dist/relay-core.test.js.map +1 -1
  44. package/dist/relay-session.test.js +34 -6
  45. package/dist/relay-session.test.js.map +1 -1
  46. package/dist/screen-recording.d.ts +2 -2
  47. package/dist/screen-recording.d.ts.map +1 -1
  48. package/dist/screen-recording.js +19 -7
  49. package/dist/screen-recording.js.map +1 -1
  50. package/dist/selector-generator.js +1 -1
  51. package/package.json +7 -7
  52. package/src/aria-snapshots/github-interactive.txt +5 -3
  53. package/src/aria-snapshots/github-raw.txt +8 -5
  54. package/src/aria-snapshots/hackernews-interactive.txt +241 -238
  55. package/src/aria-snapshots/hackernews-raw.txt +269 -265
  56. package/src/aria-snapshots/prosemirror-interactive.txt +3 -1
  57. package/src/aria-snapshots/prosemirror-raw.txt +4 -1
  58. package/src/assets/aria-labels-hacker-news.png +0 -0
  59. package/src/assets/aria-labels-old-reddit.png +0 -0
  60. package/src/cdp-relay.ts +17 -5
  61. package/src/cli-help.test.ts +63 -0
  62. package/src/cli.ts +80 -28
  63. package/src/executor.ts +65 -15
  64. package/src/ghost-cursor-client.ts +221 -96
  65. package/src/{recording-ghost-cursor.ts → ghost-cursor-controller.ts} +50 -34
  66. package/src/ghost-cursor.ts +54 -41
  67. package/src/mcp.ts +6 -1
  68. package/src/on-mouse-action.test.ts +30 -0
  69. package/src/performance-examples.ts +186 -0
  70. package/src/react-source.ts +310 -24
  71. package/src/relay-core.test.ts +117 -0
  72. package/src/relay-session.test.ts +36 -10
  73. package/src/screen-recording.ts +23 -10
  74. package/src/skill.md +33 -9
  75. package/src/snapshots/shadcn-ui-accessibility-full.md +6 -3
  76. package/src/snapshots/shadcn-ui-accessibility-interactive.md +2 -0
  77. package/dist/recording-ghost-cursor.d.ts.map +0 -1
  78. package/dist/recording-ghost-cursor.js +0 -79
  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"]
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
- // Two layers of defense:
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
- // If -e flag is provided, execute code via relay server
110
- if (options.eval) {
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: options.eval,
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
- const directEndpoint = typeof options.direct === 'string' ? options.direct : null
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 === true) {
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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 { RecordingGhostCursorController } from './recording-ghost-cursor.js'
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' && (expr as acorn.UnaryExpression).operator === 'delete')
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: acorn.Expression) => e.type === 'AssignmentExpression')
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 recordingGhostCursor = new RecordingGhostCursorController({
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 recordingGhostCursor.show({ page: targetPage, cursorOptions })
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 recordingGhostCursor.hide({ page: targetPage })
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: recordingGhostCursor,
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