playwriter 0.1.0 → 0.3.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 (76) hide show
  1. package/dist/bippy.js +5 -5
  2. package/dist/cdp-log.d.ts +4 -1
  3. package/dist/cdp-log.d.ts.map +1 -1
  4. package/dist/cdp-log.js +39 -2
  5. package/dist/cdp-log.js.map +1 -1
  6. package/dist/cdp-log.test.d.ts +2 -0
  7. package/dist/cdp-log.test.d.ts.map +1 -0
  8. package/dist/cdp-log.test.js +109 -0
  9. package/dist/cdp-log.test.js.map +1 -0
  10. package/dist/cdp-relay.d.ts.map +1 -1
  11. package/dist/cdp-relay.js +120 -11
  12. package/dist/cdp-relay.js.map +1 -1
  13. package/dist/cli-help.test.js +22 -0
  14. package/dist/cli-help.test.js.map +1 -1
  15. package/dist/cli.js +69 -25
  16. package/dist/cli.js.map +1 -1
  17. package/dist/executor.d.ts +4 -0
  18. package/dist/executor.d.ts.map +1 -1
  19. package/dist/executor.js +140 -33
  20. package/dist/executor.js.map +1 -1
  21. package/dist/extension/background.js +343 -62
  22. package/dist/extension/manifest.json +1 -1
  23. package/dist/mcp.d.ts.map +1 -1
  24. package/dist/mcp.js +6 -1
  25. package/dist/mcp.js.map +1 -1
  26. package/dist/performance-examples.d.ts +5 -0
  27. package/dist/performance-examples.d.ts.map +1 -0
  28. package/dist/performance-examples.js +112 -0
  29. package/dist/performance-examples.js.map +1 -0
  30. package/dist/performance-profiling.md +417 -0
  31. package/dist/prompt.md +51 -18
  32. package/dist/react-source.d.ts +44 -0
  33. package/dist/react-source.d.ts.map +1 -1
  34. package/dist/react-source.js +207 -20
  35. package/dist/react-source.js.map +1 -1
  36. package/dist/readability.js +1 -1
  37. package/dist/relay-client.d.ts +11 -0
  38. package/dist/relay-client.d.ts.map +1 -1
  39. package/dist/relay-client.js +46 -1
  40. package/dist/relay-client.js.map +1 -1
  41. package/dist/relay-core.test.js +10 -6
  42. package/dist/relay-core.test.js.map +1 -1
  43. package/dist/relay-session.test.js +43 -7
  44. package/dist/relay-session.test.js.map +1 -1
  45. package/dist/relay-state.test.js +57 -1
  46. package/dist/relay-state.test.js.map +1 -1
  47. package/dist/screen-recording.d.ts.map +1 -1
  48. package/dist/screen-recording.js +19 -4
  49. package/dist/screen-recording.js.map +1 -1
  50. package/dist/selector-generator.js +1 -1
  51. package/dist/start-relay-server.d.ts +1 -1
  52. package/dist/start-relay-server.d.ts.map +1 -1
  53. package/dist/start-relay-server.js +23 -1
  54. package/dist/start-relay-server.js.map +1 -1
  55. package/dist/utils.d.ts +2 -1
  56. package/dist/utils.d.ts.map +1 -1
  57. package/dist/utils.js +4 -1
  58. package/dist/utils.js.map +1 -1
  59. package/package.json +3 -3
  60. package/src/cdp-log.test.ts +131 -0
  61. package/src/cdp-log.ts +44 -2
  62. package/src/cdp-relay.ts +127 -10
  63. package/src/cli-help.test.ts +22 -0
  64. package/src/cli.ts +74 -24
  65. package/src/executor.ts +166 -39
  66. package/src/mcp.ts +6 -1
  67. package/src/performance-examples.ts +186 -0
  68. package/src/react-source.ts +310 -24
  69. package/src/relay-client.ts +62 -5
  70. package/src/relay-core.test.ts +10 -6
  71. package/src/relay-session.test.ts +45 -11
  72. package/src/relay-state.test.ts +67 -1
  73. package/src/screen-recording.ts +20 -4
  74. package/src/skill.md +62 -19
  75. package/src/start-relay-server.ts +22 -1
  76. package/src/utils.ts +5 -0
package/src/cli.ts CHANGED
@@ -105,12 +105,33 @@ cli
105
105
  .option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
106
106
  .option('-s, --session <name>', 'Session ID (required for -e, get one with `playwriter session new`)')
107
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')
108
109
  .option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
109
110
  .action(async (options) => {
110
- // If -e flag is provided, execute code via relay server
111
- 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) {
112
133
  await executeCode({
113
- code: options.eval,
134
+ code,
114
135
  timeout: options.timeout || 10000,
115
136
  sessionId: options.session,
116
137
  host: options.host,
@@ -134,15 +155,32 @@ async function getServerUrl(host?: string): Promise<string> {
134
155
  return httpBaseUrl
135
156
  }
136
157
 
137
- async function fetchExtensionsStatus(host?: string): Promise<ExtensionStatus[]> {
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
+
172
+ async function fetchExtensionsStatus({ host, token }: { host?: string; token?: string } = {}): Promise<ExtensionStatus[]> {
138
173
  try {
139
174
  const serverUrl = await getServerUrl(host)
175
+ const headers = buildAuthHeaders({ token })
140
176
  const response = await fetch(`${serverUrl}/extensions/status`, {
141
177
  signal: AbortSignal.timeout(2000),
178
+ headers,
142
179
  })
143
180
  if (!response.ok) {
144
181
  const fallback = await fetch(`${serverUrl}/extension/status`, {
145
182
  signal: AbortSignal.timeout(2000),
183
+ headers,
146
184
  })
147
185
  if (!fallback.ok) {
148
186
  return []
@@ -225,12 +263,7 @@ async function executeCode(options: {
225
263
  try {
226
264
  const response = await fetch(executeUrl, {
227
265
  method: 'POST',
228
- headers: {
229
- 'Content-Type': 'application/json',
230
- ...(token || process.env.PLAYWRITER_TOKEN
231
- ? { Authorization: `Bearer ${token || process.env.PLAYWRITER_TOKEN}` }
232
- : {}),
233
- },
266
+ headers: buildAuthHeaders({ token, json: true }),
234
267
  body: JSON.stringify({ sessionId, code, timeout, cwd }),
235
268
  })
236
269
 
@@ -321,6 +354,7 @@ interface BrowserOption {
321
354
  cli
322
355
  .command('session new', 'Create a new session and print the session ID')
323
356
  .option('--host <host>', 'Remote relay server host')
357
+ .option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
324
358
  .option('--browser <key>', 'Browser key when multiple browsers are available')
325
359
  .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')
326
360
  .action(async (options) => {
@@ -342,7 +376,7 @@ cli
342
376
  }
343
377
  await ensureRelayForSessionCreation(isLocal)
344
378
  const serverUrl = await getServerUrl(options.host)
345
- const result = await createDirectSession({ serverUrl, cdpEndpoint })
379
+ const result = await createDirectSession({ serverUrl, cdpEndpoint, token: options.token })
346
380
  console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
347
381
  console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
348
382
  return
@@ -372,7 +406,7 @@ cli
372
406
  if (instances.length === 1 && !options.browser) {
373
407
  const instance = instances[0]
374
408
  const serverUrl = await getServerUrl(options.host)
375
- const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles })
409
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles, token: options.token })
376
410
  const profileLabel = formatInstanceProfiles(instance)
377
411
  console.log(
378
412
  `Session ${result.id} created (direct CDP, ${instance.browser}${profileLabel}). Use with: playwriter -s ${result.id} -e "..."`,
@@ -396,7 +430,7 @@ cli
396
430
  process.exit(1)
397
431
  }
398
432
  const serverUrl = await getServerUrl(options.host)
399
- const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
433
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
400
434
  console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
401
435
  console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
402
436
  return
@@ -427,7 +461,7 @@ cli
427
461
  })
428
462
  }
429
463
  } else {
430
- extensions = await fetchExtensionsStatus(options.host)
464
+ extensions = await fetchExtensionsStatus({ host: options.host, token: options.token })
431
465
  }
432
466
 
433
467
  if (extensions.length === 0) {
@@ -457,8 +491,8 @@ cli
457
491
  const cwd = process.cwd()
458
492
  const response = await fetch(`${serverUrl}/cli/session/new`, {
459
493
  method: 'POST',
460
- headers: { 'Content-Type': 'application/json' },
461
- body: JSON.stringify({ extensionId, cwd }),
494
+ headers: buildAuthHeaders({ token: options.token, json: true }),
495
+ body: JSON.stringify({ extensionId, cwd, autoEnable: true }),
462
496
  })
463
497
  if (!response.ok) {
464
498
  const text = await response.text()
@@ -509,15 +543,15 @@ cli
509
543
  try {
510
544
  const serverUrl = await getServerUrl(options.host)
511
545
  if (selected.type === 'direct') {
512
- const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
546
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
513
547
  console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
514
548
  console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
515
549
  } else {
516
550
  const cwd = process.cwd()
517
551
  const response = await fetch(`${serverUrl}/cli/session/new`, {
518
552
  method: 'POST',
519
- headers: { 'Content-Type': 'application/json' },
520
- body: JSON.stringify({ extensionId: selected.extensionId, cwd }),
553
+ headers: buildAuthHeaders({ token: options.token, json: true }),
554
+ body: JSON.stringify({ extensionId: selected.extensionId, cwd, autoEnable: true }),
521
555
  })
522
556
  if (!response.ok) {
523
557
  const text = await response.text()
@@ -552,16 +586,18 @@ async function createDirectSession({
552
586
  cdpEndpoint,
553
587
  browser,
554
588
  profiles,
589
+ token,
555
590
  }: {
556
591
  serverUrl: string
557
592
  cdpEndpoint: string
558
593
  browser?: string
559
594
  profiles?: Array<{ name: string; email: string }>
595
+ token?: string
560
596
  }): Promise<{ id: string }> {
561
597
  const cwd = process.cwd()
562
598
  const response = await fetch(`${serverUrl}/cli/session/new`, {
563
599
  method: 'POST',
564
- headers: { 'Content-Type': 'application/json' },
600
+ headers: buildAuthHeaders({ token, json: true }),
565
601
  body: JSON.stringify({ cdpEndpoint, cwd, browser, profiles }),
566
602
  })
567
603
  if (!response.ok) {
@@ -622,6 +658,7 @@ function printBrowserTable(options: BrowserOption[]): void {
622
658
  cli
623
659
  .command('session list', 'List all active sessions')
624
660
  .option('--host <host>', 'Remote relay server host')
661
+ .option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
625
662
  .action(async (options) => {
626
663
  if (!options.host && !process.env.PLAYWRITER_HOST) {
627
664
  await ensureRelayServer({ logger: console, env: cliRelayEnv })
@@ -639,6 +676,7 @@ cli
639
676
 
640
677
  try {
641
678
  const response = await fetch(`${serverUrl}/cli/sessions`, {
679
+ headers: buildAuthHeaders({ token: options.token }),
642
680
  signal: AbortSignal.timeout(2000),
643
681
  })
644
682
  if (!response.ok) {
@@ -711,6 +749,7 @@ cli
711
749
  cli
712
750
  .command('session delete <sessionId>', 'Delete a session and clear its state')
713
751
  .option('--host <host>', 'Remote relay server host')
752
+ .option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
714
753
  .action(async (sessionId, options) => {
715
754
  const serverUrl = await getServerUrl(options.host)
716
755
 
@@ -721,7 +760,7 @@ cli
721
760
  try {
722
761
  const response = await fetch(`${serverUrl}/cli/session/delete`, {
723
762
  method: 'POST',
724
- headers: { 'Content-Type': 'application/json' },
763
+ headers: buildAuthHeaders({ token: options.token, json: true }),
725
764
  body: JSON.stringify({ sessionId }),
726
765
  })
727
766
 
@@ -741,6 +780,7 @@ cli
741
780
  cli
742
781
  .command('session reset <sessionId>', 'Reset the browser connection for a session')
743
782
  .option('--host <host>', 'Remote relay server host')
783
+ .option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
744
784
  .action(async (sessionId, options) => {
745
785
  const cwd = process.cwd()
746
786
  const serverUrl = await getServerUrl(options.host)
@@ -752,7 +792,7 @@ cli
752
792
  try {
753
793
  const response = await fetch(`${serverUrl}/cli/reset`, {
754
794
  method: 'POST',
755
- headers: { 'Content-Type': 'application/json' },
795
+ headers: buildAuthHeaders({ token: options.token, json: true }),
756
796
  body: JSON.stringify({ sessionId, cwd }),
757
797
  })
758
798
 
@@ -789,6 +829,14 @@ cli
789
829
  process.exit(1)
790
830
  }
791
831
 
832
+ // Expose the token to in-process callers (screen-recording.ts, etc.) so
833
+ // they can attach Authorization: Bearer ... when calling the relay's own
834
+ // privileged endpoints. Required because we no longer bypass auth for
835
+ // loopback — see commit history for the tunnel-agent threat model.
836
+ if (token) {
837
+ process.env.PLAYWRITER_TOKEN = token
838
+ }
839
+
792
840
  // Check if server is already running on the port
793
841
  const net = await import('node:net')
794
842
  const isPortInUse = await new Promise<boolean>((resolve) => {
@@ -872,6 +920,7 @@ cli
872
920
  cli
873
921
  .command('browser list', 'List all available browsers: extension-connected and direct CDP on port 9222')
874
922
  .option('--host <host>', z.string().describe('Remote relay server host'))
923
+ .option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
875
924
  .action(async (options) => {
876
925
  const isLocal = !options.host && !process.env.PLAYWRITER_HOST
877
926
 
@@ -883,7 +932,7 @@ cli
883
932
  const [extensions, directInstances] = await Promise.all([
884
933
  isLocal
885
934
  ? waitForConnectedExtensions({ timeoutMs: 2000, pollIntervalMs: 200, logger: console })
886
- : fetchExtensionsStatus(options.host),
935
+ : fetchExtensionsStatus({ host: options.host, token: options.token }),
887
936
  isLocal ? discoverChromeInstances() : Promise.resolve([] as DiscoveredInstance[]),
888
937
  ])
889
938
 
@@ -933,6 +982,7 @@ cli.command('skill', 'Print the full playwriter usage instructions').action(() =
933
982
  })
934
983
 
935
984
  cli.help()
985
+ cli.completions()
936
986
  cli.version(VERSION)
937
987
 
938
- cli.parse()
988
+ 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,
@@ -66,6 +66,7 @@ const usefulGlobals = {
66
66
  AbortController,
67
67
  AbortSignal,
68
68
  structuredClone,
69
+ process,
69
70
  } as const
70
71
 
71
72
  /**
@@ -104,14 +105,14 @@ export function getAutoReturnExpression(code: string): string | null {
104
105
  if (
105
106
  expr.type === 'AssignmentExpression' ||
106
107
  expr.type === 'UpdateExpression' ||
107
- (expr.type === 'UnaryExpression' && (expr as acorn.UnaryExpression).operator === 'delete')
108
+ (expr.type === 'UnaryExpression' && expr.operator === 'delete')
108
109
  ) {
109
110
  return null
110
111
  }
111
112
 
112
113
  // Don't auto-return sequence expressions that contain assignments
113
114
  if (expr.type === 'SequenceExpression') {
114
- const hasAssignment = expr.expressions.some((e: acorn.Expression) => e.type === 'AssignmentExpression')
115
+ const hasAssignment = expr.expressions.some((e) => e.type === 'AssignmentExpression')
115
116
  if (hasAssignment) {
116
117
  return null
117
118
  }
@@ -226,6 +227,7 @@ export interface CdpConfig {
226
227
  port?: number
227
228
  token?: string
228
229
  extensionId?: string | null
230
+ autoEnable?: boolean
229
231
  /** Direct CDP WebSocket URL — bypasses relay + extension, connects straight to Chrome */
230
232
  directCdpUrl?: string
231
233
  }
@@ -287,7 +289,12 @@ export class PlaywrightExecutor {
287
289
  private context: BrowserContext | null = null
288
290
 
289
291
  private userState: Record<string, any> = {}
290
- private browserLogs: Map<string, string[]> = new Map()
292
+ private browserLogs: Map<Page, string[]> = new Map()
293
+ // Tracks the index up to which getLatestLogs({ sinceLastCall: true }) has
294
+ // returned logs. 0 means "return everything" (first call gets full buffer).
295
+ // When addBrowserLog shifts old entries (cap at MAX_LOGS_PER_PAGE), cursors
296
+ // are decremented so they stay in sync with the array.
297
+ private pageLogCursor: Map<Page, number> = new Map()
291
298
  private lastSnapshots: WeakMap<Page, Map<string, string>> = new WeakMap()
292
299
  private lastRefToLocator: WeakMap<Page, Map<string, string>> = new WeakMap()
293
300
  private warningEvents: WarningEvent[] = []
@@ -531,41 +538,68 @@ export class PlaywrightExecutor {
531
538
  }
532
539
 
533
540
  private setupPageConsoleListener(page: Page) {
534
- // Use targetId() if available, fallback to internal _guid for CDP connections
535
- const targetId = page.targetId() || ((page as any)._guid as string | undefined)
536
- if (!targetId) {
537
- return
541
+ if (!this.browserLogs.has(page)) {
542
+ this.browserLogs.set(page, [])
538
543
  }
539
544
 
540
- if (!this.browserLogs.has(targetId)) {
541
- this.browserLogs.set(targetId, [])
542
- }
543
-
544
- page.on('framenavigated', (frame) => {
545
- if (frame === page.mainFrame()) {
546
- this.browserLogs.set(targetId, [])
547
- }
548
- })
545
+ // Logs are NOT cleared on navigation so that getLatestLogs({ sinceLastCall: true })
546
+ // can return errors from the previous page load. The MAX_LOGS_PER_PAGE cap (5000)
547
+ // prevents unbounded growth; old entries are shifted out in addBrowserLog.
549
548
 
550
549
  page.on('close', () => {
551
- this.browserLogs.delete(targetId)
550
+ this.browserLogs.delete(page)
551
+ this.pageLogCursor.delete(page)
552
552
  })
553
553
 
554
554
  page.on('console', (msg) => {
555
555
  try {
556
556
  const logEntry = `[${msg.type()}] ${msg.text()}`
557
- if (!this.browserLogs.has(targetId)) {
558
- this.browserLogs.set(targetId, [])
559
- }
560
- const pageLogs = this.browserLogs.get(targetId)!
561
- pageLogs.push(logEntry)
562
- if (pageLogs.length > MAX_LOGS_PER_PAGE) {
563
- pageLogs.shift()
564
- }
557
+ this.addBrowserLog({ page, logEntry })
565
558
  } catch (e) {
566
559
  this.logger.error('[Executor] Failed to get console message text:', e)
567
560
  }
568
561
  })
562
+
563
+ page.on('pageerror', (error) => {
564
+ this.addBrowserLog({ page, logEntry: `[pageerror] ${error.message}` })
565
+ })
566
+ }
567
+
568
+ private addBrowserLog(options: { page: Page; logEntry: string }) {
569
+ if (!this.browserLogs.has(options.page)) {
570
+ this.browserLogs.set(options.page, [])
571
+ }
572
+ const pageLogs = this.browserLogs.get(options.page)!
573
+ pageLogs.push(options.logEntry)
574
+ if (pageLogs.length > MAX_LOGS_PER_PAGE) {
575
+ pageLogs.shift()
576
+ // Decrement cursor so it stays in sync with the shifted array.
577
+ // Clamp to 0 so the cursor never goes negative.
578
+ const cursor = this.pageLogCursor.get(options.page)
579
+ if (cursor !== undefined && cursor > 0) {
580
+ this.pageLogCursor.set(options.page, cursor - 1)
581
+ }
582
+ }
583
+ }
584
+
585
+ private pagesRelatedToPage(page: Page): Page[] {
586
+ const frameUrls = new Set(
587
+ page
588
+ .frames()
589
+ .map((frame) => {
590
+ return frame.url()
591
+ })
592
+ .filter((url) => {
593
+ return url && url !== 'about:blank'
594
+ }),
595
+ )
596
+
597
+ return page
598
+ .context()
599
+ .pages()
600
+ .filter((candidate) => {
601
+ return candidate === page || frameUrls.has(candidate.url())
602
+ })
569
603
  }
570
604
 
571
605
  private async checkExtensionStatus(): Promise<{
@@ -573,17 +607,24 @@ export class PlaywrightExecutor {
573
607
  activeTargets: number
574
608
  playwriterVersion: string | null
575
609
  }> {
576
- const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig
610
+ const { host = '127.0.0.1', port = 19988, extensionId, token } = this.cdpConfig
577
611
  const { httpBaseUrl } = parseRelayHost(host, port)
578
612
  const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null }
613
+ const headers: Record<string, string> = {}
614
+ const effectiveToken = token || process.env.PLAYWRITER_TOKEN
615
+ if (effectiveToken) {
616
+ headers['Authorization'] = `Bearer ${effectiveToken}`
617
+ }
579
618
  try {
580
619
  if (extensionId) {
581
620
  const response = await fetch(`${httpBaseUrl}/extensions/status`, {
582
621
  signal: AbortSignal.timeout(2000),
622
+ headers,
583
623
  })
584
624
  if (!response.ok) {
585
625
  const fallback = await fetch(`${httpBaseUrl}/extension/status`, {
586
626
  signal: AbortSignal.timeout(2000),
627
+ headers,
587
628
  })
588
629
  if (!fallback.ok) {
589
630
  return notConnected
@@ -617,6 +658,7 @@ export class PlaywrightExecutor {
617
658
 
618
659
  const response = await fetch(`${httpBaseUrl}/extension/status`, {
619
660
  signal: AbortSignal.timeout(2000),
661
+ headers,
620
662
  })
621
663
  if (!response.ok) {
622
664
  return notConnected
@@ -968,21 +1010,50 @@ export class PlaywrightExecutor {
968
1010
  })
969
1011
  }
970
1012
 
971
- const getLatestLogs = async (options?: { page?: Page; count?: number; search?: string | RegExp }) => {
972
- const { page: filterPage, count, search } = options || {}
1013
+ const getLatestLogs = async (options?: {
1014
+ page?: Page
1015
+ count?: number
1016
+ search?: string | RegExp
1017
+ // When true, only return logs added since the last getLatestLogs call
1018
+ // with sinceLastCall: true. First call returns all buffered logs.
1019
+ // Cursors are tracked per page so navigations and new logs are
1020
+ // never missed. Useful for checking page errors after each action.
1021
+ sinceLastCall?: boolean
1022
+ }) => {
1023
+ const { page: filterPage, count, search, sinceLastCall = false } = options || {}
973
1024
  let allLogs: string[] = []
974
1025
 
975
- if (filterPage) {
976
- // Use targetId() if available, fallback to internal _guid for CDP connections
977
- const targetId = filterPage.targetId() || ((filterPage as any)._guid as string | undefined)
978
- if (!targetId) {
979
- throw new Error('Could not get page targetId')
1026
+ // Collect logs, optionally slicing from cursor when sinceLastCall is set
1027
+ const collectLogs = (targetPage: Page): string[] => {
1028
+ const logs = this.browserLogs.get(targetPage) || []
1029
+ if (!sinceLastCall) {
1030
+ return logs
980
1031
  }
981
- const pageLogs = this.browserLogs.get(targetId) || []
982
- allLogs = [...pageLogs]
1032
+ const cursor = this.pageLogCursor.get(targetPage) || 0
1033
+ return logs.slice(cursor)
1034
+ }
1035
+
1036
+ if (filterPage) {
1037
+ const relatedPages = this.pagesRelatedToPage(filterPage)
1038
+ allLogs = relatedPages.flatMap((relatedPage) => {
1039
+ return collectLogs(relatedPage)
1040
+ })
983
1041
  } else {
984
- for (const pageLogs of this.browserLogs.values()) {
985
- allLogs.push(...pageLogs)
1042
+ for (const [p] of this.browserLogs) {
1043
+ allLogs.push(...collectLogs(p))
1044
+ }
1045
+ }
1046
+
1047
+ // Advance cursors after collecting so next sinceLastCall call starts fresh
1048
+ if (sinceLastCall) {
1049
+ const pagesToAdvance = filterPage
1050
+ ? this.pagesRelatedToPage(filterPage)
1051
+ : [...this.browserLogs.keys()]
1052
+ for (const p of pagesToAdvance) {
1053
+ const logs = this.browserLogs.get(p)
1054
+ if (logs) {
1055
+ this.pageLogCursor.set(p, logs.length)
1056
+ }
986
1057
  }
987
1058
  }
988
1059
 
@@ -1021,6 +1092,7 @@ export class PlaywrightExecutor {
1021
1092
 
1022
1093
  const clearAllLogs = () => {
1023
1094
  this.browserLogs.clear()
1095
+ this.pageLogCursor.clear()
1024
1096
  }
1025
1097
 
1026
1098
  const getCDPSession = async (options: { page: Page }) => {
@@ -1043,6 +1115,47 @@ export class PlaywrightExecutor {
1043
1115
  return getReactSource({ locator: options.locator, cdp })
1044
1116
  }
1045
1117
 
1118
+ const getReactComponentInfoFn = async (options: { locator: Locator | ElementHandle }) => {
1119
+ const targetPage = await (async (): Promise<Page | null> => {
1120
+ if ('page' in options.locator) {
1121
+ return options.locator.page()
1122
+ }
1123
+
1124
+ return (await options.locator.ownerFrame())?.page() ?? null
1125
+ })()
1126
+ if (!targetPage) {
1127
+ throw new Error('Could not get page from locator')
1128
+ }
1129
+ const cdp = await getCDPSession({ page: targetPage })
1130
+ return getReactComponentInfo({ locator: options.locator, cdp })
1131
+ }
1132
+
1133
+ const inspectPinnedElement = async (pageUrl: string, elementExpression: string) => {
1134
+ const targetPage = context.pages().find((candidate) => candidate.url() === pageUrl) || context.pages()[0]
1135
+ if (!targetPage) {
1136
+ throw new Error('No Playwright pages are available')
1137
+ }
1138
+
1139
+ this.userState.page = targetPage
1140
+ const handle = (await targetPage.evaluateHandle((expression) => {
1141
+ return Function(`return (${expression})`)()
1142
+ }, elementExpression)).asElement()
1143
+
1144
+ const result = await (async () => {
1145
+ if (!handle) {
1146
+ return { url: targetPage.url(), outerHTML: null, react: null }
1147
+ }
1148
+ return {
1149
+ url: targetPage.url(),
1150
+ outerHTML: await handle.evaluate((el) => el.outerHTML),
1151
+ react: await getReactComponentInfoFn({ locator: handle }),
1152
+ }
1153
+ })()
1154
+
1155
+ console.log(result)
1156
+ return result
1157
+ }
1158
+
1046
1159
  const screenshotCollector: ScreenshotResult[] = []
1047
1160
  // Separate collector for images produced by resizeImageForAgent() calls.
1048
1161
  // These get merged into result.images so the CLI can emit them via Kitty Graphics.
@@ -1146,6 +1259,8 @@ export class PlaywrightExecutor {
1146
1259
  getStylesForLocator: getStylesForLocatorFn,
1147
1260
  formatStylesAsText,
1148
1261
  getReactSource: getReactSourceFn,
1262
+ getReactComponentInfo: getReactComponentInfoFn,
1263
+ inspectPinnedElement,
1149
1264
  screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
1150
1265
  resizeImageForAgent: resizeImageForAgentFn,
1151
1266
  // Backward-compatible alias for resizeImageForAgent
@@ -1178,6 +1293,18 @@ export class PlaywrightExecutor {
1178
1293
  // Ghost Browser API - only works in Ghost Browser, mirrors chrome.ghostPublicAPI etc
1179
1294
  chrome: chromeGhostBrowser,
1180
1295
  ...usefulGlobals,
1296
+ // Expose process with safety overrides:
1297
+ // - cwd() returns the session's cwd instead of the relay server's cwd
1298
+ // - exit() is blocked to prevent killing the relay server
1299
+ // - chdir() is blocked to prevent affecting other sessions
1300
+ process: new Proxy(process, {
1301
+ get(target, prop, receiver) {
1302
+ if (prop === 'cwd') return () => self.sessionCwd || target.cwd()
1303
+ if (prop === 'exit') return () => { throw new Error('process.exit() is not allowed in the sandbox') }
1304
+ if (prop === 'chdir') return () => { throw new Error('process.chdir() is not allowed in the sandbox, use a new session with a different cwd instead') }
1305
+ return Reflect.get(target, prop, receiver)
1306
+ },
1307
+ }),
1181
1308
  }
1182
1309
 
1183
1310
  const vmContext = vm.createContext(vmContextObj)
package/src/mcp.ts CHANGED
@@ -56,9 +56,14 @@ function getLogServerUrl(): string {
56
56
 
57
57
  async function sendLogToRelayServer(level: string, ...args: any[]) {
58
58
  try {
59
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
60
+ const token = process.env.PLAYWRITER_TOKEN
61
+ if (token) {
62
+ headers['Authorization'] = `Bearer ${token}`
63
+ }
59
64
  await fetch(getLogServerUrl(), {
60
65
  method: 'POST',
61
- headers: { 'Content-Type': 'application/json' },
66
+ headers,
62
67
  body: JSON.stringify({ level, args }),
63
68
  signal: AbortSignal.timeout(1000),
64
69
  })