playwriter 0.0.33 → 0.0.37

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 (65) hide show
  1. package/dist/aria-snapshot.d.ts +68 -0
  2. package/dist/aria-snapshot.d.ts.map +1 -0
  3. package/dist/aria-snapshot.js +359 -0
  4. package/dist/aria-snapshot.js.map +1 -0
  5. package/dist/cdp-relay.d.ts.map +1 -1
  6. package/dist/cdp-relay.js +95 -5
  7. package/dist/cdp-relay.js.map +1 -1
  8. package/dist/cdp-session.d.ts +24 -3
  9. package/dist/cdp-session.d.ts.map +1 -1
  10. package/dist/cdp-session.js +23 -0
  11. package/dist/cdp-session.js.map +1 -1
  12. package/dist/debugger-api.md +4 -3
  13. package/dist/debugger.d.ts +4 -3
  14. package/dist/debugger.d.ts.map +1 -1
  15. package/dist/debugger.js +3 -1
  16. package/dist/debugger.js.map +1 -1
  17. package/dist/editor-api.md +2 -2
  18. package/dist/editor.d.ts +2 -2
  19. package/dist/editor.d.ts.map +1 -1
  20. package/dist/editor.js +1 -0
  21. package/dist/editor.js.map +1 -1
  22. package/dist/index.d.ts +8 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/mcp.d.ts.map +1 -1
  27. package/dist/mcp.js +151 -14
  28. package/dist/mcp.js.map +1 -1
  29. package/dist/mcp.test.js +340 -5
  30. package/dist/mcp.test.js.map +1 -1
  31. package/dist/protocol.d.ts +12 -1
  32. package/dist/protocol.d.ts.map +1 -1
  33. package/dist/react-source.d.ts +3 -3
  34. package/dist/react-source.d.ts.map +1 -1
  35. package/dist/react-source.js +3 -1
  36. package/dist/react-source.js.map +1 -1
  37. package/dist/scoped-fs.d.ts +94 -0
  38. package/dist/scoped-fs.d.ts.map +1 -0
  39. package/dist/scoped-fs.js +356 -0
  40. package/dist/scoped-fs.js.map +1 -0
  41. package/dist/styles-api.md +3 -3
  42. package/dist/styles.d.ts +3 -3
  43. package/dist/styles.d.ts.map +1 -1
  44. package/dist/styles.js +3 -1
  45. package/dist/styles.js.map +1 -1
  46. package/package.json +13 -13
  47. package/src/aria-snapshot.ts +446 -0
  48. package/src/assets/aria-labels-github-snapshot.txt +605 -0
  49. package/src/assets/aria-labels-github.png +0 -0
  50. package/src/assets/aria-labels-google-snapshot.txt +110 -0
  51. package/src/assets/aria-labels-google.png +0 -0
  52. package/src/assets/aria-labels-hacker-news-snapshot.txt +1023 -0
  53. package/src/assets/aria-labels-hacker-news.png +0 -0
  54. package/src/cdp-relay.ts +103 -5
  55. package/src/cdp-session.ts +50 -3
  56. package/src/debugger.ts +6 -4
  57. package/src/editor.ts +4 -3
  58. package/src/index.ts +8 -0
  59. package/src/mcp.test.ts +424 -5
  60. package/src/mcp.ts +242 -66
  61. package/src/prompt.md +209 -167
  62. package/src/protocol.ts +14 -1
  63. package/src/react-source.ts +5 -3
  64. package/src/scoped-fs.ts +411 -0
  65. package/src/styles.ts +5 -3
package/src/cdp-relay.ts CHANGED
@@ -41,6 +41,23 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
41
41
  reject: (error: Error) => void
42
42
  }>()
43
43
  let extensionMessageId = 0
44
+ let extensionPingInterval: ReturnType<typeof setInterval> | null = null
45
+
46
+ function startExtensionPing() {
47
+ if (extensionPingInterval) {
48
+ clearInterval(extensionPingInterval)
49
+ }
50
+ extensionPingInterval = setInterval(() => {
51
+ extensionWs?.send(JSON.stringify({ method: 'ping' }))
52
+ }, 5000)
53
+ }
54
+
55
+ function stopExtensionPing() {
56
+ if (extensionPingInterval) {
57
+ clearInterval(extensionPingInterval)
58
+ extensionPingInterval = null
59
+ }
60
+ }
44
61
 
45
62
  function logCdpMessage({
46
63
  direction,
@@ -176,6 +193,40 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
176
193
  })
177
194
  }
178
195
 
196
+ // Auto-create initial tab when PLAYWRITER_AUTO_ENABLE is set and no targets exist.
197
+ // This allows Playwright to connect and immediately have a page to work with.
198
+ async function maybeAutoCreateInitialTab(): Promise<void> {
199
+ if (!process.env.PLAYWRITER_AUTO_ENABLE) {
200
+ return
201
+ }
202
+ if (!extensionWs) {
203
+ return
204
+ }
205
+ if (connectedTargets.size > 0) {
206
+ return
207
+ }
208
+
209
+ try {
210
+ logger?.log(chalk.blue('Auto-creating initial tab for Playwright client'))
211
+ const result = await sendToExtension({ method: 'createInitialTab', timeout: 10000 }) as {
212
+ success: boolean
213
+ tabId: number
214
+ sessionId: string
215
+ targetInfo: Protocol.Target.TargetInfo
216
+ }
217
+ if (result.success && result.sessionId && result.targetInfo) {
218
+ connectedTargets.set(result.sessionId, {
219
+ sessionId: result.sessionId,
220
+ targetId: result.targetInfo.targetId,
221
+ targetInfo: result.targetInfo
222
+ })
223
+ logger?.log(chalk.blue(`Auto-created tab, now have ${connectedTargets.size} targets, url: ${result.targetInfo.url}`))
224
+ }
225
+ } catch (e) {
226
+ logger?.error('Failed to auto-create initial tab:', e)
227
+ }
228
+ }
229
+
179
230
  async function routeCdpCommand({ method, params, sessionId }: { method: string; params: any; sessionId?: string }) {
180
231
  switch (method) {
181
232
  case 'Browser.getVersion': {
@@ -192,10 +243,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
192
243
  return {}
193
244
  }
194
245
 
246
+ // Target.setAutoAttach is a CDP command Playwright sends on first connection.
247
+ // We use it as the hook to auto-create an initial tab. If Playwright changes
248
+ // its initialization sequence in the future, this could be moved to a different command.
195
249
  case 'Target.setAutoAttach': {
196
250
  if (sessionId) {
197
251
  break
198
252
  }
253
+ await maybeAutoCreateInitialTab()
199
254
  return {}
200
255
  }
201
256
 
@@ -331,7 +386,26 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
331
386
  }
332
387
  })
333
388
 
389
+ // Validate Origin header for WebSocket connections to prevent cross-origin attacks.
390
+ // Browsers always send Origin header for WebSocket connections, but Node.js clients don't.
391
+ // We reject browser origins (except chrome-extension://) to prevent malicious websites
392
+ // from connecting to the local WebSocket server.
393
+ function isAllowedOrigin(origin: string | undefined): boolean {
394
+ if (!origin) {
395
+ return true // Node.js clients don't send Origin
396
+ }
397
+ if (origin.startsWith('chrome-extension://')) {
398
+ return true // Chrome extension is allowed
399
+ }
400
+ return false // Reject browser origins (http://, https://, etc.)
401
+ }
402
+
334
403
  app.get('/cdp/:clientId?', (c, next) => {
404
+ const origin = c.req.header('origin')
405
+ if (!isAllowedOrigin(origin)) {
406
+ logger?.log(chalk.red(`Rejecting /cdp WebSocket from origin: ${origin}`))
407
+ return c.text('Forbidden', 403)
408
+ }
335
409
  if (token) {
336
410
  const url = new URL(c.req.url, 'http://localhost')
337
411
  const providedToken = url.searchParams.get('token')
@@ -344,15 +418,16 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
344
418
  const clientId = c.req.param('clientId') || 'default'
345
419
 
346
420
  return {
347
- onOpen(_event, ws) {
421
+ async onOpen(_event, ws) {
348
422
  if (playwrightClients.has(clientId)) {
349
423
  logger?.log(chalk.red(`Rejecting duplicate client ID: ${clientId}`))
350
424
  ws.close(1000, 'Client ID already connected')
351
425
  return
352
426
  }
353
427
 
428
+ // Add client first so it can receive Target.attachedToTarget events
354
429
  playwrightClients.set(clientId, { id: clientId, ws })
355
- logger?.log(chalk.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total)`))
430
+ logger?.log(chalk.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionWs}) (${connectedTargets.size} pages)`))
356
431
  },
357
432
 
358
433
  async onMessage(event, ws) {
@@ -404,6 +479,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
404
479
  waitingForDebugger: false
405
480
  }
406
481
  } satisfies CDPEventFor<'Target.attachedToTarget'>
482
+ if (!target.targetInfo.url) {
483
+ logger?.error(chalk.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'), JSON.stringify(attachedPayload))
484
+ }
407
485
  logger?.log(chalk.magenta('[Server] Target.attachedToTarget full payload:'), JSON.stringify(attachedPayload))
408
486
  sendToPlaywright({
409
487
  message: attachedPayload,
@@ -424,6 +502,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
424
502
  }
425
503
  }
426
504
  } satisfies CDPEventFor<'Target.targetCreated'>
505
+ if (!target.targetInfo.url) {
506
+ logger?.error(chalk.red('[Server] WARNING: Target.targetCreated sent with empty URL!'), JSON.stringify(targetCreatedPayload))
507
+ }
427
508
  logger?.log(chalk.magenta('[Server] Target.targetCreated full payload:'), JSON.stringify(targetCreatedPayload))
428
509
  sendToPlaywright({
429
510
  message: targetCreatedPayload,
@@ -448,6 +529,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
448
529
  waitingForDebugger: false
449
530
  }
450
531
  } satisfies CDPEventFor<'Target.attachedToTarget'>
532
+ if (!target.targetInfo.url) {
533
+ logger?.error(chalk.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'), JSON.stringify(attachedPayload))
534
+ }
451
535
  logger?.log(chalk.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'), JSON.stringify(attachedPayload))
452
536
  sendToPlaywright({
453
537
  message: attachedPayload,
@@ -483,7 +567,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
483
567
  }
484
568
  }))
485
569
 
486
- app.get('/extension', upgradeWebSocket(() => {
570
+ app.get('/extension', (c, next) => {
571
+ const origin = c.req.header('origin')
572
+ if (!isAllowedOrigin(origin)) {
573
+ logger?.log(chalk.red(`Rejecting /extension WebSocket from origin: ${origin}`))
574
+ return c.text('Forbidden', 403)
575
+ }
576
+ return next()
577
+ }, upgradeWebSocket(() => {
487
578
  return {
488
579
  onOpen(_event, ws) {
489
580
  if (extensionWs) {
@@ -504,6 +595,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
504
595
  }
505
596
 
506
597
  extensionWs = ws
598
+ startExtensionPing()
507
599
  logger?.log('Extension connected with clean state')
508
600
  },
509
601
 
@@ -517,7 +609,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
517
609
  return
518
610
  }
519
611
 
520
- if ('id' in message) {
612
+ if (message.id !== undefined) {
521
613
  const pending = extensionPendingRequests.get(message.id)
522
614
  if (!pending) {
523
615
  logger?.log('Unexpected response with id:', message.id)
@@ -531,6 +623,8 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
531
623
  } else {
532
624
  pending.resolve(message.result)
533
625
  }
626
+ } else if (message.method === 'pong') {
627
+ // Keep-alive response, nothing to do
534
628
  } else if (message.method === 'log') {
535
629
  const { level, args } = message.params
536
630
  const logFn = (logger as any)?.[level] || logger?.log
@@ -558,6 +652,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
558
652
  if (method === 'Target.attachedToTarget') {
559
653
  const targetParams = params as Protocol.Target.AttachedToTargetEvent
560
654
 
655
+ if (!targetParams.targetInfo.url) {
656
+ logger?.error(chalk.red('[Extension] WARNING: Target.attachedToTarget received with empty URL!'), JSON.stringify({ method, params: targetParams, sessionId }))
657
+ }
561
658
  logger?.log(chalk.yellow('[Extension] Target.attachedToTarget full payload:'), JSON.stringify({ method, params: targetParams, sessionId }))
562
659
 
563
660
  // Check if we already sent this target to clients (e.g., from Target.setAutoAttach response)
@@ -681,7 +778,8 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
681
778
  },
682
779
 
683
780
  onClose(event, ws) {
684
- logger?.log('Extension disconnected')
781
+ logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'}`)
782
+ stopExtensionPing()
685
783
 
686
784
  // If this is an old connection closing after we've already established a new one,
687
785
  // don't clear the global state
@@ -3,12 +3,26 @@ import type { Page } from 'playwright-core'
3
3
  import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'
4
4
  import type { CDPResponseBase, CDPEventBase } from './cdp-types.js'
5
5
 
6
+ /**
7
+ * Common interface for CDP sessions that works with both our CDPSession
8
+ * and Playwright's CDPSession. Use this type when you want to accept either.
9
+ *
10
+ * Uses loose types so Playwright's CDPSession (which uses Protocol.Events)
11
+ * is assignable to this interface.
12
+ */
13
+ export interface ICDPSession {
14
+ send(method: string, params?: object): Promise<unknown>
15
+ on(event: string, callback: (params: any) => void): unknown
16
+ off(event: string, callback: (params: any) => void): unknown
17
+ detach(): Promise<void>
18
+ }
19
+
6
20
  interface PendingRequest {
7
21
  resolve: (result: unknown) => void
8
22
  reject: (error: Error) => void
9
23
  }
10
24
 
11
- export class CDPSession {
25
+ export class CDPSession implements ICDPSession {
12
26
  private ws: WebSocket
13
27
  private pendingRequests = new Map<number, PendingRequest>()
14
28
  private eventListeners = new Map<string, Set<(params: unknown) => void>>()
@@ -90,15 +104,48 @@ export class CDPSession {
90
104
  })
91
105
  }
92
106
 
93
- on<K extends keyof ProtocolMapping.Events>(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void) {
107
+ on<K extends keyof ProtocolMapping.Events>(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void): this {
94
108
  if (!this.eventListeners.has(event)) {
95
109
  this.eventListeners.set(event, new Set())
96
110
  }
97
111
  this.eventListeners.get(event)!.add(callback as (params: unknown) => void)
112
+ return this
113
+ }
114
+
115
+ /** Alias for `on` - matches Playwright's CDPSession interface */
116
+ addListener<K extends keyof ProtocolMapping.Events>(
117
+ event: K,
118
+ callback: (params: ProtocolMapping.Events[K][0]) => void,
119
+ ): this {
120
+ return this.on(event, callback)
98
121
  }
99
122
 
100
- off<K extends keyof ProtocolMapping.Events>(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void) {
123
+ off<K extends keyof ProtocolMapping.Events>(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void): this {
101
124
  this.eventListeners.get(event)?.delete(callback as (params: unknown) => void)
125
+ return this
126
+ }
127
+
128
+ /** Alias for `off` - matches Playwright's CDPSession interface */
129
+ removeListener<K extends keyof ProtocolMapping.Events>(
130
+ event: K,
131
+ callback: (params: ProtocolMapping.Events[K][0]) => void,
132
+ ): this {
133
+ return this.off(event, callback)
134
+ }
135
+
136
+ /** Listen for an event once, then automatically remove the listener */
137
+ once<K extends keyof ProtocolMapping.Events>(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void): this {
138
+ const onceCallback = (params: ProtocolMapping.Events[K][0]) => {
139
+ this.off(event, onceCallback)
140
+ callback(params)
141
+ }
142
+ return this.on(event, onceCallback)
143
+ }
144
+
145
+ /** Alias for `close` - matches Playwright's CDPSession interface */
146
+ detach(): Promise<void> {
147
+ this.close()
148
+ return Promise.resolve()
102
149
  }
103
150
 
104
151
  close() {
package/src/debugger.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CDPSession } from './cdp-session.js'
1
+ import type { ICDPSession, CDPSession } from './cdp-session.js'
2
2
  import type { Protocol } from 'devtools-protocol'
3
3
 
4
4
  export interface BreakpointInfo {
@@ -61,7 +61,8 @@ export class Debugger {
61
61
  * Creates a new Debugger instance.
62
62
  *
63
63
  * @param options - Configuration options
64
- * @param options.cdp - A CDPSession instance for sending CDP commands
64
+ * @param options.cdp - A CDPSession instance for sending CDP commands (works with both
65
+ * our CDPSession and Playwright's CDPSession)
65
66
  *
66
67
  * @example
67
68
  * ```ts
@@ -69,8 +70,9 @@ export class Debugger {
69
70
  * const dbg = new Debugger({ cdp })
70
71
  * ```
71
72
  */
72
- constructor({ cdp }: { cdp: CDPSession }) {
73
- this.cdp = cdp
73
+ constructor({ cdp }: { cdp: ICDPSession }) {
74
+ // Cast to CDPSession for internal type safety - at runtime both are compatible
75
+ this.cdp = cdp as CDPSession
74
76
  this.setupEventListeners()
75
77
  }
76
78
 
package/src/editor.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CDPSession } from './cdp-session.js'
1
+ import type { ICDPSession, CDPSession } from './cdp-session.js'
2
2
 
3
3
  export interface ReadResult {
4
4
  content: string
@@ -52,8 +52,9 @@ export class Editor {
52
52
  private stylesheets = new Map<string, string>()
53
53
  private sourceCache = new Map<string, string>()
54
54
 
55
- constructor({ cdp }: { cdp: CDPSession }) {
56
- this.cdp = cdp
55
+ constructor({ cdp }: { cdp: ICDPSession }) {
56
+ // Cast to CDPSession for internal type safety - at runtime both are compatible
57
+ this.cdp = cdp as CDPSession
57
58
  this.setupEventListeners()
58
59
  }
59
60
 
package/src/index.ts CHANGED
@@ -1,2 +1,10 @@
1
1
  export * from './cdp-relay.js'
2
2
  export * from './utils.js'
3
+ export { CDPSession, getCDPSessionForPage } from './cdp-session.js'
4
+ export type { ICDPSession } from './cdp-session.js'
5
+ export { Editor } from './editor.js'
6
+ export type { ReadResult, SearchMatch, EditResult } from './editor.js'
7
+ export { Debugger } from './debugger.js'
8
+ export type { BreakpointInfo, LocationInfo, EvaluateResult, ScriptInfo } from './debugger.js'
9
+ export { getAriaSnapshot, showAriaRefLabels, hideAriaRefLabels } from './aria-snapshot.js'
10
+ export type { AriaRef, AriaSnapshotResult } from './aria-snapshot.js'