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
@@ -730,6 +730,7 @@ describe('Relay Core Tests', () => {
730
730
  console.error('Test error 67890');
731
731
  console.warn('Test warning 11111');
732
732
  console.log('Test log 2 with', { data: 'object' });
733
+ setTimeout(() => { throw new Error('Test pageerror 22222'); }, 0);
733
734
  });
734
735
  // Wait for logs to be captured
735
736
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -752,6 +753,7 @@ describe('Relay Core Tests', () => {
752
753
  expect(output).toContain('[log] Test log 12345')
753
754
  expect(output).toContain('[error] Test error 67890')
754
755
  expect(output).toContain('[warning] Test warning 11111')
756
+ expect(output).toContain('[pageerror] Test pageerror 22222')
755
757
 
756
758
  // Test filtering by search string
757
759
  const errorLogsResult = await client.callTool({
@@ -769,7 +771,7 @@ describe('Relay Core Tests', () => {
769
771
  // With context lines (5 above/below), nearby logs are also included
770
772
  expect(errorOutput).toContain('[log] Test log 12345')
771
773
 
772
- // Test that logs are cleared on page reload
774
+ // Test that logs persist across page reload
773
775
  await client.callTool({
774
776
  name: 'execute',
775
777
  arguments: {
@@ -812,7 +814,9 @@ describe('Relay Core Tests', () => {
812
814
  },
813
815
  })
814
816
 
815
- // Check logs after reload - old logs should be gone
817
+ // Check logs after reload - old logs persist (no longer cleared on navigation)
818
+ // so both pre- and post-reload logs are present. Use sinceLastCall to get
819
+ // only new logs if needed.
816
820
  const afterReloadResult = await client.callTool({
817
821
  name: 'execute',
818
822
  arguments: {
@@ -826,7 +830,7 @@ describe('Relay Core Tests', () => {
826
830
 
827
831
  const afterReloadOutput = (afterReloadResult as any).content[0].text
828
832
  expect(afterReloadOutput).toContain('[log] After reload 88888')
829
- expect(afterReloadOutput).not.toContain('[log] Before reload 99999')
833
+ expect(afterReloadOutput).toContain('[log] Before reload 99999')
830
834
 
831
835
  // Clean up
832
836
  await client.callTool({
@@ -943,7 +947,7 @@ describe('Relay Core Tests', () => {
943
947
  expect(allOutput).toContain('[log] PageA log 11111')
944
948
  expect(allOutput).toContain('[log] PageB log 33333')
945
949
 
946
- // Test that reloading page A clears only page A logs
950
+ // Test that reloading page A preserves logs (no longer cleared on navigation)
947
951
  await client.callTool({
948
952
  name: 'execute',
949
953
  arguments: {
@@ -957,7 +961,7 @@ describe('Relay Core Tests', () => {
957
961
  },
958
962
  })
959
963
 
960
- // Check page A logs - should only have new log
964
+ // Check page A logs - logs persist across navigation, so both old and new are present
961
965
  const pageAAfterReloadResult = await client.callTool({
962
966
  name: 'execute',
963
967
  arguments: {
@@ -971,7 +975,7 @@ describe('Relay Core Tests', () => {
971
975
 
972
976
  const pageAAfterReloadOutput = (pageAAfterReloadResult as any).content[0].text
973
977
  expect(pageAAfterReloadOutput).toContain('[log] PageA after reload 55555')
974
- expect(pageAAfterReloadOutput).not.toContain('[log] PageA log 11111')
978
+ expect(pageAAfterReloadOutput).toContain('[log] PageA log 11111')
975
979
 
976
980
  // Check page B logs - should still have original logs
977
981
  const pageBAfterAReloadResult = await client.callTool({
@@ -921,12 +921,24 @@ describe('CDP Session Tests', () => {
921
921
  <body>
922
922
  <div id="root"></div>
923
923
  <script>
924
- function MyComponent() {
925
- return React.createElement('button', { id: 'react-btn' }, 'Click me');
924
+ function SaveButton(props) {
925
+ return React.createElement('button', { id: 'react-btn' }, props.label);
926
+ }
927
+ function Panel() {
928
+ return React.createElement('section', null, React.createElement(SaveButton, {
929
+ label: 'Click me',
930
+ count: 3,
931
+ config: { variant: 'primary' },
932
+ onClick: () => {}
933
+ }));
934
+ }
935
+ function App() {
936
+ return React.createElement(Panel);
926
937
  }
927
938
  const root = ReactDOM.createRoot(document.getElementById('root'));
928
- root.render(React.createElement(MyComponent));
939
+ root.render(React.createElement(App));
929
940
  </script>
941
+ <button id="plain-btn">Plain</button>
930
942
  </body>
931
943
  </html>
932
944
  `)
@@ -946,40 +958,54 @@ describe('CDP Session Tests', () => {
946
958
  const btnCount = await btn.count()
947
959
  expect(btnCount).toBe(1)
948
960
 
949
- const hasBippyBefore = await cdpPage!.evaluate(() => !!(globalThis as any).__bippy)
961
+ const hasBippyBefore = await cdpPage!.evaluate(() => !!globalThis.__bippy)
950
962
  expect(hasBippyBefore).toBe(false)
951
963
 
952
964
  const wsUrl = getCdpUrl({ port: TEST_PORT })
953
965
  const cdpSession = await getCDPSessionForPage({ page: cdpPage! })
954
966
 
955
- const { getReactSource } = await import('./react-source.js')
967
+ const { getReactSource, getReactComponentInfo } = await import('./react-source.js')
956
968
  const source = await getReactSource({ locator: btn, cdp: cdpSession })
969
+ const info = await getReactComponentInfo({ locator: btn, cdp: cdpSession })
970
+ const plainInfo = await getReactComponentInfo({ locator: cdpPage!.locator('#plain-btn'), cdp: cdpSession })
957
971
 
958
- const hasBippyAfter = await cdpPage!.evaluate(() => !!(globalThis as any).__bippy)
972
+ const hasBippyAfter = await cdpPage!.evaluate(() => !!globalThis.__bippy)
959
973
  expect(hasBippyAfter).toBe(true)
960
974
 
961
975
  const hasFiber = await btn.evaluate((el) => {
962
- const bippy = (globalThis as any).__bippy
976
+ const bippy = globalThis.__bippy
977
+ if (!bippy) return false
963
978
  const fiber = bippy.getFiberFromHostInstance(el)
964
979
  return !!fiber
965
980
  })
966
981
  expect(hasFiber).toBe(true)
967
982
 
968
983
  const componentName = await btn.evaluate((el) => {
969
- const bippy = (globalThis as any).__bippy
984
+ const bippy = globalThis.__bippy
985
+ if (!bippy) return null
970
986
  const fiber = bippy.getFiberFromHostInstance(el)
971
987
  let current = fiber
972
988
  while (current) {
973
989
  if (bippy.isCompositeFiber(current)) {
974
990
  return bippy.getDisplayName(current.type)
975
991
  }
976
- current = current.return
992
+ current = current.return ?? null
977
993
  }
978
994
  return null
979
995
  })
980
- expect(componentName).toBe('MyComponent')
996
+ expect(componentName).toBe('SaveButton')
997
+ expect(plainInfo).toBe(null)
998
+ expect(info?.componentName).toBe('SaveButton')
999
+ expect(info?.hierarchy.map((item) => item.componentName)).toEqual(['SaveButton', 'Panel', 'App'])
1000
+ expect(info?.props).toEqual({
1001
+ label: 'Click me',
1002
+ count: 3,
1003
+ config: { variant: 'primary' },
1004
+ onClick: '[function]',
1005
+ })
981
1006
 
982
1007
  console.log('Component name from fiber:', componentName)
1008
+ console.log('React component info:', info)
983
1009
  console.log('Source location (null for UMD React, works on local dev servers with JSX transform):', source)
984
1010
 
985
1011
  await browser.close()
@@ -1251,7 +1277,15 @@ describe('Auto-enable Tests', () => {
1251
1277
  })
1252
1278
  expect(tabCountBefore).toBe(0)
1253
1279
 
1254
- const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
1280
+ const previousAutoEnable = process.env.PLAYWRITER_AUTO_ENABLE
1281
+ delete process.env.PLAYWRITER_AUTO_ENABLE
1282
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT, autoEnable: true })).finally(() => {
1283
+ if (previousAutoEnable === undefined) {
1284
+ delete process.env.PLAYWRITER_AUTO_ENABLE
1285
+ return
1286
+ }
1287
+ process.env.PLAYWRITER_AUTO_ENABLE = previousAutoEnable
1288
+ })
1255
1289
 
1256
1290
  const pages = browser.contexts()[0].pages()
1257
1291
  expect(pages.length).toBeGreaterThan(0)
@@ -2,7 +2,7 @@
2
2
  * Unit tests for relay state transitions.
3
3
  * Data-in / data-out transitions for the unified extension map.
4
4
  */
5
- import { describe, test, expect } from 'vitest'
5
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest'
6
6
  import type { WSContext } from 'hono/ws'
7
7
  import type { Protocol } from './cdp-types.js'
8
8
  import * as relayState from './relay-state.js'
@@ -568,3 +568,69 @@ describe('store.setState with transitions', () => {
568
568
  expect(state.playwrightClients.size).toBe(1)
569
569
  })
570
570
  })
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // DNS rebinding protection — Host header validation
574
+ // Node.js fetch/undici treats Host as a forbidden header, so we use the raw
575
+ // http module to send requests with arbitrary Host values.
576
+ // ---------------------------------------------------------------------------
577
+
578
+ import http from 'node:http'
579
+
580
+ function httpGet({ port, path, host }: { port: number; path: string; host: string }): Promise<{ status: number; body: string }> {
581
+ return new Promise((resolve, reject) => {
582
+ const req = http.request({ hostname: '127.0.0.1', port, path, method: 'GET', headers: { Host: host } }, (res) => {
583
+ let body = ''
584
+ res.on('data', (chunk: Buffer) => {
585
+ body += chunk.toString()
586
+ })
587
+ res.on('end', () => {
588
+ resolve({ status: res.statusCode!, body })
589
+ })
590
+ })
591
+ req.on('error', reject)
592
+ req.end()
593
+ })
594
+ }
595
+
596
+ describe('Host header validation (DNS rebinding protection)', () => {
597
+ let server: { close(): void } | null = null
598
+ const TEST_PORT = 19996
599
+
600
+ beforeAll(async () => {
601
+ const { startPlayWriterCDPRelayServer } = await import('./cdp-relay.js')
602
+ server = await startPlayWriterCDPRelayServer({ port: TEST_PORT })
603
+ })
604
+
605
+ afterAll(async () => {
606
+ server?.close()
607
+ server = null
608
+ })
609
+
610
+ test('rejects requests with non-localhost Host header', async () => {
611
+ // DNS rebinding: evil.com resolves to 127.0.0.1
612
+ const res = await httpGet({ port: TEST_PORT, path: '/', host: 'evil.com' })
613
+ expect(res.status).toBe(403)
614
+ expect(res.body).toContain('Invalid Host header')
615
+
616
+ // With port suffix
617
+ const res2 = await httpGet({ port: TEST_PORT, path: '/version', host: 'evil.com:19996' })
618
+ expect(res2.status).toBe(403)
619
+ })
620
+
621
+ test('allows requests with localhost Host headers', async () => {
622
+ const localhostVariants = [
623
+ `localhost:${TEST_PORT}`,
624
+ `127.0.0.1:${TEST_PORT}`,
625
+ `[::1]:${TEST_PORT}`,
626
+ 'localhost',
627
+ '127.0.0.1',
628
+ 'LOCALHOST',
629
+ ]
630
+
631
+ for (const hostValue of localhostVariants) {
632
+ const res = await httpGet({ port: TEST_PORT, path: '/', host: hostValue })
633
+ expect(res.status, `Expected 200 for Host: ${hostValue}`).toBe(200)
634
+ }
635
+ })
636
+ })
@@ -18,6 +18,21 @@ import type {
18
18
  } from './protocol.js'
19
19
  import { GhostCursorController } from './ghost-cursor-controller.js'
20
20
 
21
+ /**
22
+ * Build headers for the relay's privileged /recording/* HTTP endpoints.
23
+ * Reads PLAYWRITER_TOKEN from env so in-process callers (executor running
24
+ * inside `playwriter serve --token …`) authenticate against their own relay.
25
+ * The `serve` command sets the env var at startup.
26
+ */
27
+ function recordingHeaders(): Record<string, string> {
28
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
29
+ const token = process.env.PLAYWRITER_TOKEN
30
+ if (token) {
31
+ headers['Authorization'] = `Bearer ${token}`
32
+ }
33
+ return headers
34
+ }
35
+
21
36
  /**
22
37
  * Generate a CLI command that starts a managed Playwriter browser with the
23
38
  * bundled extension preloaded. This enables screen recording without a manual
@@ -293,7 +308,7 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
293
308
 
294
309
  const response = await fetch(`http://127.0.0.1:${relayPort}/recording/start`, {
295
310
  method: 'POST',
296
- headers: { 'Content-Type': 'application/json' },
311
+ headers: recordingHeaders(),
297
312
  body: JSON.stringify({
298
313
  sessionId,
299
314
  frameRate,
@@ -341,7 +356,7 @@ export async function stopRecording(
341
356
 
342
357
  const response = await fetch(`http://127.0.0.1:${relayPort}/recording/stop`, {
343
358
  method: 'POST',
344
- headers: { 'Content-Type': 'application/json' },
359
+ headers: recordingHeaders(),
345
360
  body: JSON.stringify({ sessionId }),
346
361
  })
347
362
 
@@ -368,7 +383,8 @@ export async function isRecording(options: {
368
383
  if (sessionId) {
369
384
  url.searchParams.set('sessionId', sessionId)
370
385
  }
371
- const response = await fetch(url.toString())
386
+ // GET request only the Authorization header matters here
387
+ const response = await fetch(url.toString(), { headers: recordingHeaders() })
372
388
  const result = (await response.json()) as IsRecordingResult
373
389
 
374
390
  return { isRecording: result.isRecording, startedAt: result.startedAt, tabId: result.tabId }
@@ -386,7 +402,7 @@ export async function cancelRecording(options: {
386
402
 
387
403
  const response = await fetch(`http://127.0.0.1:${relayPort}/recording/cancel`, {
388
404
  method: 'POST',
389
- headers: { 'Content-Type': 'application/json' },
405
+ headers: recordingHeaders(),
390
406
  body: JSON.stringify({ sessionId }),
391
407
  })
392
408
 
package/src/skill.md CHANGED
@@ -93,7 +93,7 @@ playwriter -s 1 -e 'await state.page.click("button")'
93
93
  playwriter -s 1 -e 'await state.page.title()'
94
94
 
95
95
  # Take a screenshot
96
- playwriter -s 1 -e 'await state.page.screenshot({ path: "screenshot.png", scale: "css" })'
96
+ playwriter -s 1 -e 'await state.page.screenshot({ path: "/absolute/path/to/screenshot.png", scale: "css" })'
97
97
 
98
98
  # Get accessibility snapshot
99
99
  playwriter -s 1 -e 'await snapshot({ page: state.page })'
@@ -129,6 +129,16 @@ console.log({ title, url });
129
129
  - **Heredoc** (`<<'EOF'`): best for multiline code. The quoted `'EOF'` delimiter disables all bash expansion. Any character works inside, including `$`, backticks, and single quotes.
130
130
  - **`$'...'`**: allows `\'` escaping but `\n`, `\t`, `\\` become special — conflicts with JS regex patterns.
131
131
 
132
+ ### Execute from file
133
+
134
+ For longer scripts, use `-f` instead of `-e` to execute JavaScript from a file:
135
+
136
+ ```bash
137
+ playwriter -s 1 -f script.js
138
+ ```
139
+
140
+ The file is read from disk and executed in the same sandbox as `-e`. All context variables (`state`, `page`, `context`, etc.) are available. `-e` and `-f` cannot be used together.
141
+
132
142
  ### Debugging playwriter issues
133
143
 
134
144
  If some internal critical error happens you can read the relay server logs to understand the issue. The log file is located in the user home directory:
@@ -193,10 +203,19 @@ You can collaborate with the user - they can help with captchas, difficult eleme
193
203
  - `page` - a default page (may be shared with other agents). Prefer creating your own page and storing it in `state` (see "working with pages")
194
204
  - `context` - browser context, access all pages via `context.pages()`
195
205
  - `require` - load Node.js modules (e.g., `const fs = require('node:fs')`). ESM `import` is not available in the sandbox
196
- - Node.js globals: `setTimeout`, `setInterval`, `fetch`, `URL`, `Buffer`, `crypto`, etc.
206
+ - Node.js globals: `setTimeout`, `setInterval`, `fetch`, `URL`, `Buffer`, `crypto`, `process`, etc.
207
+
208
+ **Not available in the sandbox:** `__dirname`, `__filename`, `import`.
197
209
 
198
210
  **Important:** `state` is **session-isolated** but pages are **shared** across all sessions. See "working with pages" for how to avoid interference.
199
211
 
212
+ **Sandboxed `fs` write restrictions:** `require('node:fs')` is scoped. Writes (writeFileSync, mkdirSync, etc.) only succeed in:
213
+ - The **directory where `playwriter` CLI was invoked** (the session's cwd)
214
+ - `/tmp`
215
+ - The OS temp directory (`os.tmpdir()`, e.g. `/var/folders/.../T/` on macOS)
216
+
217
+ Writing to any other path (e.g. `~/Downloads`, `~/Desktop`) throws `EPERM: operation not permitted, access outside allowed directories`. To save files elsewhere, write to a temp path first, then move the file using a shell command outside the sandbox.
218
+
200
219
  ## rules
201
220
 
202
221
  - **Initialize state.page first**: see "working with pages" — at the start of a task, assign `state.page` (reuse `about:blank` or create one) and use `state.page` for all automation steps.
@@ -205,10 +224,12 @@ You can collaborate with the user - they can help with captchas, difficult eleme
205
224
  - **No bringToFront**: never call unless user asks - it's disruptive and unnecessary, you can interact with background pages
206
225
  - **Check state after actions**: always verify page state after clicking/submitting (see next section)
207
226
  - **Clean up listeners**: call `state.page.removeAllListeners()` at end of message to prevent leaks
227
+ - **Always print page logs after every action**: call `getLatestLogs({ page: state.page, sinceLastCall: true })` after every goto, click, or submit to catch console errors and warnings. Do not manually collect `page.on('console')` events; manual listeners miss logs emitted before the listener is attached. The first `sinceLastCall` call returns all buffered logs including startup and hydration errors.
208
228
  - **CDP sessions**: use `getCDPSession({ page: state.page })` not `state.page.context().newCDPSession()` - NEVER use `newCDPSession()` method, it doesn't work through playwriter relay
209
229
  - **Wait for load**: use `state.page.waitForLoadState('domcontentloaded')` not `state.page.waitForEvent('load')` - waitForEvent times out if already loaded
210
230
  - **Minimize timeouts**: prefer proper waits (`waitForSelector`, `waitForPageLoad`) over `state.page.waitForTimeout()`. Short timeouts (1-2s) are acceptable for non-deterministic events like animations, tab opens, or async UI updates where no specific selector is available
211
231
  - **Snapshot before screenshot**: always use `snapshot()` first to understand page state (text-based, fast, cheap). Only use `screenshot` when you specifically need visual/spatial information. Never take a screenshot just to check if a page loaded or to read text content — snapshot gives you that instantly without burning image tokens
232
+ - **Always use absolute file paths for Playwright artifact APIs**: for `page.screenshot({ path })`, `locator.screenshot({ path })`, `elementHandle.screenshot({ path })`, `page.pdf({ path })`, `download.saveAs(path)`, and `video.saveAs(path)`, always pass an absolute path. Relative paths are resolved by Playwright client internals, not the sandboxed `fs`, so they may use the relay server cwd instead of your session cwd.
212
233
  - **Snapshot replaces page.evaluate() for inspection**: do NOT write `page.evaluate()` calls to manually query class names, bounding boxes, child counts, or visibility flags. `snapshot()` already shows every interactive element with its text, role, and a ready-to-use locator. If you catch yourself writing `document.querySelector` or `getBoundingClientRect` inside evaluate — stop and use `snapshot()` instead. Reserve `page.evaluate()` for actions that modify page state (e.g., `localStorage.clear()`, scroll manipulation) or extract non-DOM data (e.g., `window.__CONFIG__`)
213
234
 
214
235
  ## interaction feedback loop
@@ -216,18 +237,21 @@ You can collaborate with the user - they can help with captchas, difficult eleme
216
237
  Every browser interaction must follow **observe → act → observe**. Never chain multiple actions blindly.
217
238
 
218
239
  1. **Open page** — get or create your page, navigate to URL
219
- 2. **Observe** — print `state.page.url()` + `snapshot()`. Always print URL — pages can redirect unexpectedly.
240
+ 2. **Observe** — print `state.page.url()` + `snapshot()` + `getLatestLogs({ sinceLastCall: true })`. Always print URL — pages can redirect unexpectedly.
220
241
  3. **Check** — if page isn't ready (loading, wrong URL, content missing), wait and observe again
221
242
  4. **Act** — perform one action (click, type, submit)
222
- 5. **Observe again** — print URL + snapshot to verify the action's effect
243
+ 5. **Observe again** — print URL + snapshot + page logs to verify the action's effect
223
244
  6. **Repeat** from step 3 until task is complete
224
245
 
246
+ **Always print page logs after every action** using `getLatestLogs({ sinceLastCall: true })`. This returns only new console messages and errors since the last call, so you catch hydration errors, failed network requests, and runtime exceptions without duplicates. The first call returns all buffered logs from the page, including logs emitted before your script started.
247
+
225
248
  ```js
226
249
  // Each step should be a separate execute call:
227
250
  // Step 1: navigate + observe
228
251
  state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
229
252
  await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
230
253
  console.log('URL:', state.page.url())
254
+ console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
231
255
  await snapshot({ page: state.page }).then(console.log)
232
256
  ```
233
257
 
@@ -235,25 +259,26 @@ await snapshot({ page: state.page }).then(console.log)
235
259
  // Step 2: act + observe
236
260
  await state.page.locator('button:has-text("Submit")').click()
237
261
  console.log('URL:', state.page.url())
262
+ console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
238
263
  await snapshot({ page: state.page }).then(console.log)
239
264
  ```
240
265
 
241
266
  If nothing changed after an action, try `waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element.
242
267
 
243
- **Deeper observation** — when snapshots aren't enough to understand what happened, combine multiple channels:
268
+ **Deeper observation** — when snapshots aren't enough to understand what happened, combine snapshot with filtered logs:
244
269
 
245
270
  ```js
246
- // Check console for errors after an action
271
+ // Search for specific errors in all logs (not just since last call)
247
272
  const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 })
248
273
 
249
- // Combine snapshot + logs for full picture
274
+ // Combine snapshot + filtered logs for full picture
250
275
  const snap = await snapshot({ page: state.page, search: /dialog|error|message/ })
251
276
  const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 })
252
277
  console.log('UI:', snap)
253
278
  console.log('Logs:', logs)
254
279
  ```
255
280
 
256
- Use `getLatestLogs()` for console errors, `state.page.url()` for navigation, screenshots only for visual layout issues.
281
+ Use `getLatestLogs({ sinceLastCall: true })` after every action, `getLatestLogs({ search })` for targeted debugging, `state.page.url()` for navigation, screenshots only for visual layout issues.
257
282
 
258
283
  ## common mistakes to avoid
259
284
 
@@ -601,7 +626,7 @@ Instead, use simpler alternatives (single download via `a.click()`, store data i
601
626
 
602
627
  ```js
603
628
  const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')])
604
- await download.saveAs(`/tmp/${download.suggestedFilename()}`)
629
+ await download.saveAs(`/absolute/path/${download.suggestedFilename()}`)
605
630
  ```
606
631
 
607
632
  **iFrames** - two approaches depending on what you need:
@@ -669,17 +694,22 @@ For carousels or lazy-loaded galleries, you may need to click navigation arrows
669
694
 
670
695
  ## utility functions
671
696
 
672
- **getLatestLogs** - retrieve captured browser console logs (up to 5000 per page, cleared on navigation):
697
+ **getLatestLogs** - retrieve captured browser console logs and page errors (up to 5000 per page):
698
+
699
+ Always use this helper when inspecting browser logs. Do not attach new `page.on('console')` listeners for debugging because they only see future events and can miss logs emitted during page startup or hydration.
700
+
701
+ Use `sinceLastCall: true` after every action to get only new logs since the previous call. The first call returns all buffered logs including pre-existing ones. Logs persist across navigations so you never miss errors from page transitions.
673
702
 
674
703
  ```js
675
- await getLatestLogs({ page?, count?, search? })
676
- // Examples:
704
+ await getLatestLogs({ page?, count?, search?, sinceLastCall? })
705
+ // After every action: get only new logs
706
+ const newLogs = await getLatestLogs({ page: state.page, sinceLastCall: true })
707
+ // Search all logs (ignores cursor):
677
708
  const errors = await getLatestLogs({ search: /error/i, count: 50 })
678
- const pageLogs = await getLatestLogs({ page: state.page })
709
+ const pageLogs = await getLatestLogs({ page: state.page, count: 100 })
710
+ const hydrationErrors = await getLatestLogs({ page: state.page, search: /hydration|pageerror|React/i })
679
711
  ```
680
712
 
681
- For custom log collection across runs, store in state: `state.logs = []; state.page.on('console', m => state.logs.push(m.text()))`
682
-
683
713
  **getCleanHTML** - get cleaned HTML from a locator or page, with search and diffing:
684
714
 
685
715
  ```js
@@ -754,6 +784,19 @@ const source = await getReactSource({ locator: state.page.locator('[data-testid=
754
784
  // => { fileName, lineNumber, columnNumber, componentName }
755
785
  ```
756
786
 
787
+ **getReactComponentInfo** - get best-effort React component info for an element. Returns `null` for non-React elements and never throws just because an element was not rendered by React. Source locations are usually only available in React dev builds. Props are sanitized and truncated so functions, DOM nodes, circular refs, and huge objects do not flood the output.
788
+
789
+ ```js
790
+ const info = await getReactComponentInfo({ locator: state.page.locator('[data-testid="submit-btn"]') })
791
+ // => { componentName, source, hierarchy, props } | null
792
+ ```
793
+
794
+ **inspectPinnedElement** - inspect a Playwriter pinned element and print the element `outerHTML` plus React component info when available. Used by the in-page toolbar and right-click copy flow.
795
+
796
+ ```js
797
+ await inspectPinnedElement('https://example.com', 'globalThis.playwriterPinnedElem1')
798
+ ```
799
+
757
800
  **getStylesForLocator** - inspect CSS styles applied to an element, like browser DevTools "Styles" panel. Useful for debugging styling issues, finding where a CSS property is defined (file:line), and checking inherited styles. Returns selector, source location, and declarations for each matching rule. ALWAYS fetch `https://playwriter.dev/resources/styles-api.md` first with curl or webfetch tool.
758
801
 
759
802
  ```js
@@ -806,7 +849,7 @@ await screenshotWithAccessibilityLabels({ page: state.page })
806
849
 
807
850
  Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs.
808
851
 
809
- **resizeImageForAgent** - shrink an image so it consumes fewer tokens when read back into context. The resized image is automatically included in the response (visible to the LLM). `await resizeImageForAgent({ input: './screenshot.png' })`. Also accepts `width`, `height`, `maxDimension`, `quality`, `format` (default: `'png'`), `output`. Alias: `resizeImage`.
852
+ **resizeImageForAgent** - shrink an image so it consumes fewer tokens when read back into context. The resized image is automatically included in the response (visible to the LLM). `await resizeImageForAgent({ input: '/absolute/path/to/screenshot.png' })`. Also accepts `width`, `height`, `maxDimension`, `quality`, `format` (default: `'png'`), `output`. Alias: `resizeImage`.
810
853
 
811
854
  **recording.start / recording.stop** - record the page as a video at native FPS (30-60fps). Uses `chrome.tabCapture` so **recording survives page navigation**. Auto-overlays a ghost cursor that follows mouse actions. Requires user to have clicked the Playwriter extension icon on the tab. Auto-resizes viewport to 16:9 (override with `aspectRatio: null`). Auto-stops after 15 min (override with `maxDurationMs`).
812
855
 
@@ -815,7 +858,7 @@ For demos, use interaction methods (`locator.click()`, `page.mouse.move()`) inst
815
858
  ```js
816
859
  await recording.start({
817
860
  page: state.page,
818
- outputPath: './recording.mp4',
861
+ outputPath: '/absolute/path/to/recording.mp4',
819
862
  frameRate: 30, // default
820
863
  audio: false, // default (tab audio)
821
864
  videoBitsPerSecond: 2500000,
@@ -869,7 +912,7 @@ await el.click()
869
912
  Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:
870
913
 
871
914
  ```js
872
- await state.page.screenshot({ path: 'shot.png', scale: 'css' })
915
+ await state.page.screenshot({ path: '/absolute/path/to/shot.png', scale: 'css' })
873
916
  ```
874
917
 
875
918
  If you want to read back the image file into context, resize it first so it consumes fewer tokens:
@@ -1048,7 +1091,7 @@ await state.page.setViewportSize({ width: 1280, height: 720 })
1048
1091
  ### region screenshot (zoom equivalent)
1049
1092
 
1050
1093
  ```js
1051
- await state.page.screenshot({ path: 'region.png', scale: 'css', clip: { x: 100, y: 200, width: 400, height: 300 } })
1094
+ await state.page.screenshot({ path: '/absolute/path/to/region.png', scale: 'css', clip: { x: 100, y: 200, width: 400, height: 300 } })
1052
1095
  ```
1053
1096
 
1054
1097
  Prefer locator-based actions over coordinates — locators are stable across scroll/resize, auto-wait for elements, and don't require screenshot round-trips that burn ~800 image tokens per cycle.
@@ -1,5 +1,6 @@
1
1
  import { startPlayWriterCDPRelayServer } from './cdp-relay.js'
2
2
  import { createFileLogger } from './create-logger.js'
3
+ import { waitForRelayVersion } from './relay-client.js'
3
4
  import { LOG_CDP_FILE_PATH } from './utils.js'
4
5
 
5
6
  process.title = 'playwriter-ws-server'
@@ -25,7 +26,27 @@ export async function startServer({
25
26
  host = '127.0.0.1',
26
27
  token,
27
28
  }: { port?: number; host?: string; token?: string } = {}) {
28
- const server = await startPlayWriterCDPRelayServer({ port, host, token, logger })
29
+ let server
30
+ try {
31
+ server = await startPlayWriterCDPRelayServer({ port, host, token, logger })
32
+ } catch (err: unknown) {
33
+ // When two relay processes race to start (issue #75), the loser gets
34
+ // EADDRINUSE. Check if the winner is a valid relay and exit cleanly
35
+ // instead of crashing with a scary error in the logs.
36
+ const errWithCode = err as NodeJS.ErrnoException
37
+ if (errWithCode?.code === 'EADDRINUSE') {
38
+ // The winner may have bound the port but not be ready to answer /version
39
+ // yet, so poll for up to 2 seconds before giving up.
40
+ const version = await waitForRelayVersion({ port })
41
+ if (version) {
42
+ await logger.log(`Another relay (v${version}) already bound to port ${port}, exiting gracefully`)
43
+ process.exit(0)
44
+ }
45
+ await logger.error(`Port ${port} is in use by a non-relay process`)
46
+ process.exit(1)
47
+ }
48
+ throw err
49
+ }
29
50
 
30
51
  console.log('CDP Relay Server running. Press Ctrl+C to stop.')
31
52
  console.log('Logs are being written to:', logger.logFilePath)
package/src/utils.ts CHANGED
@@ -36,11 +36,13 @@ export function getCdpUrl({
36
36
  host = '127.0.0.1',
37
37
  token,
38
38
  extensionId,
39
+ autoEnable,
39
40
  }: {
40
41
  port?: number
41
42
  host?: string
42
43
  token?: string
43
44
  extensionId?: string | null
45
+ autoEnable?: boolean
44
46
  } = {}) {
45
47
  const id = `${Math.random().toString(36).substring(2, 15)}_${Date.now()}`
46
48
  const params = new URLSearchParams()
@@ -50,6 +52,9 @@ export function getCdpUrl({
50
52
  if (extensionId) {
51
53
  params.set('extensionId', extensionId)
52
54
  }
55
+ if (autoEnable) {
56
+ params.set('autoEnable', '1')
57
+ }
53
58
  const queryString = params.toString()
54
59
  const suffix = queryString ? `?${queryString}` : ''
55
60
  const { wsBaseUrl } = parseRelayHost(host, port)