playwriter 0.1.0 → 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 (44) 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.js +22 -0
  6. package/dist/cli-help.test.js.map +1 -1
  7. package/dist/cli.js +61 -20
  8. package/dist/cli.js.map +1 -1
  9. package/dist/executor.d.ts.map +1 -1
  10. package/dist/executor.js +38 -1
  11. package/dist/executor.js.map +1 -1
  12. package/dist/extension/background.js +322 -52
  13. package/dist/extension/manifest.json +1 -1
  14. package/dist/mcp.d.ts.map +1 -1
  15. package/dist/mcp.js +6 -1
  16. package/dist/mcp.js.map +1 -1
  17. package/dist/performance-examples.d.ts +5 -0
  18. package/dist/performance-examples.d.ts.map +1 -0
  19. package/dist/performance-examples.js +112 -0
  20. package/dist/performance-examples.js.map +1 -0
  21. package/dist/performance-profiling.md +417 -0
  22. package/dist/prompt.md +19 -5
  23. package/dist/react-source.d.ts +44 -0
  24. package/dist/react-source.d.ts.map +1 -1
  25. package/dist/react-source.js +207 -20
  26. package/dist/react-source.js.map +1 -1
  27. package/dist/readability.js +1 -1
  28. package/dist/relay-session.test.js +34 -6
  29. package/dist/relay-session.test.js.map +1 -1
  30. package/dist/screen-recording.d.ts.map +1 -1
  31. package/dist/screen-recording.js +19 -4
  32. package/dist/screen-recording.js.map +1 -1
  33. package/dist/selector-generator.js +1 -1
  34. package/package.json +3 -3
  35. package/src/cdp-relay.ts +17 -5
  36. package/src/cli-help.test.ts +22 -0
  37. package/src/cli.ts +66 -19
  38. package/src/executor.ts +47 -4
  39. package/src/mcp.ts +6 -1
  40. package/src/performance-examples.ts +186 -0
  41. package/src/react-source.ts +310 -24
  42. package/src/relay-session.test.ts +36 -10
  43. package/src/screen-recording.ts +20 -4
  44. package/src/skill.md +30 -6
@@ -11,6 +11,67 @@ export interface ReactSourceLocation {
11
11
  componentName: string | null
12
12
  }
13
13
 
14
+ export type ReactSerializedProp =
15
+ | string
16
+ | number
17
+ | boolean
18
+ | null
19
+ | ReactSerializedProp[]
20
+ | { [key: string]: ReactSerializedProp }
21
+
22
+ export interface ReactComponentHierarchyItem {
23
+ componentName: string | null
24
+ source: Omit<ReactSourceLocation, 'componentName'> | null
25
+ props: ReactSerializedProp
26
+ }
27
+
28
+ export interface ReactComponentInfo {
29
+ componentName: string | null
30
+ source: Omit<ReactSourceLocation, 'componentName'> | null
31
+ hierarchy: ReactComponentHierarchyItem[]
32
+ props: ReactSerializedProp
33
+ }
34
+
35
+ type ReactInspectableValue =
36
+ | string
37
+ | number
38
+ | boolean
39
+ | bigint
40
+ | symbol
41
+ | null
42
+ | undefined
43
+ | object
44
+ | ((...args: never[]) => ReactInspectableValue)
45
+
46
+ type BrowserElement = object
47
+
48
+ interface BippySourceFrame {
49
+ fileName?: string | null
50
+ lineNumber?: number | null
51
+ columnNumber?: number | null
52
+ functionName?: string | null
53
+ }
54
+
55
+ interface BippyFiber {
56
+ return?: BippyFiber | null
57
+ type?: ReactInspectableValue
58
+ memoizedProps?: ReactInspectableValue
59
+ }
60
+
61
+ interface BippyRuntime {
62
+ getFiberFromHostInstance(el: BrowserElement): BippyFiber | null
63
+ getSource(fiber: BippyFiber): Promise<BippySourceFrame | null>
64
+ getOwnerStack(fiber: BippyFiber): Promise<BippySourceFrame[]>
65
+ getDisplayName(type: ReactInspectableValue): string | null
66
+ isCompositeFiber(fiber: BippyFiber): boolean
67
+ normalizeFileName(fileName: string): string
68
+ isSourceFile(fileName: string): boolean
69
+ }
70
+
71
+ declare global {
72
+ var __bippy: BippyRuntime | undefined
73
+ }
74
+
14
75
  let bippyCode: string | null = null
15
76
 
16
77
  function getBippyCode(): string {
@@ -23,6 +84,31 @@ function getBippyCode(): string {
23
84
  return bippyCode
24
85
  }
25
86
 
87
+ async function getPageFromTarget(target: Locator | ElementHandle): Promise<Page> {
88
+ if ('page' in target) {
89
+ return target.page()
90
+ }
91
+
92
+ const frame = await target.ownerFrame()
93
+ if (!frame) {
94
+ throw new Error('Could not get frame from element handle')
95
+ }
96
+ return frame.page()
97
+ }
98
+
99
+ async function ensureBippy({ page, cdp }: { page: Page; cdp: ICDPSession }): Promise<void> {
100
+ const hasBippy = await page.evaluate(() => {
101
+ return !!globalThis.__bippy
102
+ })
103
+
104
+ if (hasBippy) {
105
+ return
106
+ }
107
+
108
+ const code = getBippyCode()
109
+ await cdp.send('Runtime.evaluate', { expression: code })
110
+ }
111
+
26
112
  export async function getReactSource({
27
113
  locator,
28
114
  cdp: cdpSession,
@@ -31,25 +117,27 @@ export async function getReactSource({
31
117
  cdp: ICDPSession
32
118
  }): Promise<ReactSourceLocation | null> {
33
119
  const cdp = cdpSession
34
- const page: Page = 'page' in locator && typeof locator.page === 'function' ? locator.page() : (locator as any)._page
35
-
36
- if (!page) {
37
- throw new Error('Could not get page from locator')
38
- }
39
-
40
- const hasBippy = await page.evaluate(() => !!(globalThis as any).__bippy)
41
-
42
- if (!hasBippy) {
43
- const code = getBippyCode()
44
- await cdp.send('Runtime.evaluate', { expression: code })
45
- }
120
+ const page = await getPageFromTarget(locator)
121
+ await ensureBippy({ page, cdp })
46
122
 
47
- const result = await (locator as any).evaluate(async (el: any) => {
48
- const bippy = (globalThis as any).__bippy
123
+ const evaluateReactSource = async (
124
+ el: BrowserElement,
125
+ ): Promise<(ReactSourceLocation & { _notFound?: undefined }) | { _notFound: 'fiber' | 'source' }> => {
126
+ const bippy = globalThis.__bippy
49
127
  if (!bippy) {
50
128
  throw new Error('bippy not loaded')
51
129
  }
52
130
 
131
+ // bippy.normalizeFileName strips "/app-pages-browser/" but not the parenthesized
132
+ // form "/(app-pages-browser)/" that Next.js webpack actually uses. This strips
133
+ // all webpack layer prefixes like (app-pages-browser), (ssr), (rsc), etc.
134
+ const cleanName = (name: string): string => {
135
+ let f = bippy.normalizeFileName(name)
136
+ f = f.replace(/^\/?\([-\w]+\)\//, '')
137
+ f = f.replace(/^\.\//, '')
138
+ return f
139
+ }
140
+
53
141
  const fiber = bippy.getFiberFromHostInstance(el)
54
142
  if (!fiber) {
55
143
  return { _notFound: 'fiber' as const }
@@ -58,7 +146,7 @@ export async function getReactSource({
58
146
  const source = await bippy.getSource(fiber)
59
147
  if (source) {
60
148
  return {
61
- fileName: source.fileName ? bippy.normalizeFileName(source.fileName) : null,
149
+ fileName: source.fileName ? cleanName(source.fileName) : null,
62
150
  lineNumber: source.lineNumber ?? null,
63
151
  columnNumber: source.columnNumber ?? null,
64
152
  componentName: source.functionName ?? bippy.getDisplayName(fiber.type) ?? null,
@@ -69,7 +157,7 @@ export async function getReactSource({
69
157
  for (const frame of ownerStack) {
70
158
  if (frame.fileName && bippy.isSourceFile(frame.fileName)) {
71
159
  return {
72
- fileName: bippy.normalizeFileName(frame.fileName),
160
+ fileName: cleanName(frame.fileName),
73
161
  lineNumber: frame.lineNumber ?? null,
74
162
  columnNumber: frame.columnNumber ?? null,
75
163
  componentName: frame.functionName ?? null,
@@ -78,16 +166,214 @@ export async function getReactSource({
78
166
  }
79
167
 
80
168
  return { _notFound: 'source' as const }
81
- })
169
+ }
170
+
171
+ const resolveResult = (
172
+ result: (ReactSourceLocation & { _notFound?: undefined }) | { _notFound: 'fiber' | 'source' },
173
+ ): ReactSourceLocation | null => {
174
+ if (result?._notFound) {
175
+ if (result._notFound === 'fiber') {
176
+ console.warn('[getReactSource] no fiber found - is this a React element?')
177
+ } else {
178
+ console.warn('[getReactSource] no source location found - is this a React dev build?')
179
+ }
180
+ return null
181
+ }
182
+
183
+ return result
184
+ }
185
+
186
+ if ('page' in locator) {
187
+ return resolveResult(await locator.evaluate(evaluateReactSource))
188
+ }
189
+
190
+ return resolveResult(await locator.evaluate(evaluateReactSource))
191
+ }
192
+
193
+ export async function getReactComponentInfo({
194
+ locator,
195
+ cdp: cdpSession,
196
+ }: {
197
+ locator: Locator | ElementHandle
198
+ cdp: ICDPSession
199
+ }): Promise<ReactComponentInfo | null> {
200
+ const cdp = cdpSession
201
+ const page = await getPageFromTarget(locator)
202
+ await ensureBippy({ page, cdp })
203
+
204
+ const evaluateReactComponentInfo = async (el: BrowserElement): Promise<ReactComponentInfo | null> => {
205
+ const bippy = globalThis.__bippy
206
+ if (!bippy) {
207
+ throw new Error('bippy not loaded')
208
+ }
209
+
210
+ // bippy.normalizeFileName strips "/app-pages-browser/" but not the parenthesized
211
+ // form "/(app-pages-browser)/" that Next.js webpack actually uses. This strips
212
+ // all webpack layer prefixes like (app-pages-browser), (ssr), (rsc), etc.
213
+ const cleanName = (name: string): string => {
214
+ let f = bippy.normalizeFileName(name)
215
+ f = f.replace(/^\/?\([-\w]+\)\//, '')
216
+ f = f.replace(/^\.\//, '')
217
+ return f
218
+ }
219
+
220
+ const serializeReactValue = (
221
+ value: ReactInspectableValue,
222
+ options: { depth: number; seen: WeakSet<object> },
223
+ ): ReactSerializedProp => {
224
+ if (value === null) {
225
+ return null
226
+ }
227
+ if (typeof value === 'string') {
228
+ return value.length > 300 ? `${value.slice(0, 300)}…[truncated]` : value
229
+ }
230
+ if (typeof value === 'number' || typeof value === 'boolean') {
231
+ return value
232
+ }
233
+ if (typeof value === 'undefined') {
234
+ return '[undefined]'
235
+ }
236
+ if (typeof value === 'function') {
237
+ return '[function]'
238
+ }
239
+ if (typeof value === 'symbol') {
240
+ return '[symbol]'
241
+ }
242
+ if (typeof value === 'bigint') {
243
+ return `${value.toString()}n`
244
+ }
245
+ if (typeof value !== 'object') {
246
+ return `[${typeof value}]`
247
+ }
248
+ const objectTag = Object.prototype.toString.call(value)
249
+ if (objectTag.includes('Element]') || objectTag === '[object Window]' || objectTag === '[object Document]') {
250
+ return '[dom-node]'
251
+ }
252
+ if (options.seen.has(value)) {
253
+ return '[circular]'
254
+ }
255
+ if (options.depth >= 3) {
256
+ return '[max-depth]'
257
+ }
258
+
259
+ options.seen.add(value)
260
+
261
+ if (Array.isArray(value)) {
262
+ const items = value.slice(0, 20).map((item) => {
263
+ return serializeReactValue(item, { depth: options.depth + 1, seen: options.seen })
264
+ })
265
+ if (value.length > 20) {
266
+ items.push(`…[${value.length - 20} more]`)
267
+ }
268
+ options.seen.delete(value)
269
+ return items
270
+ }
271
+
272
+ const entries = Object.entries(value).slice(0, 20)
273
+ const result: { [key: string]: ReactSerializedProp } = Object.fromEntries(
274
+ entries.map(([key, childValue]) => {
275
+ return [key, serializeReactValue(childValue, { depth: options.depth + 1, seen: options.seen })]
276
+ }),
277
+ )
278
+ const totalKeys = Object.keys(value).length
279
+ if (totalKeys > 20) {
280
+ result['…'] = `[${totalKeys - 20} more keys]`
281
+ }
282
+ options.seen.delete(value)
283
+ return result
284
+ }
82
285
 
83
- if (result && '_notFound' in result) {
84
- if (result._notFound === 'fiber') {
85
- console.warn('[getReactSource] no fiber found - is this a React element?')
86
- } else {
87
- console.warn('[getReactSource] no source location found - is this a React dev build?')
286
+ const getSourceForFiber = async (fiber: BippyFiber): Promise<Omit<ReactSourceLocation, 'componentName'> | null> => {
287
+ try {
288
+ const source = await bippy.getSource(fiber)
289
+ if (source?.fileName) {
290
+ return {
291
+ fileName: cleanName(source.fileName),
292
+ lineNumber: source.lineNumber ?? null,
293
+ columnNumber: source.columnNumber ?? null,
294
+ }
295
+ }
296
+
297
+ const ownerStack = await bippy.getOwnerStack(fiber)
298
+ const frame = ownerStack.find((ownerFrame) => {
299
+ return ownerFrame.fileName ? bippy.isSourceFile(ownerFrame.fileName) : false
300
+ })
301
+ if (frame?.fileName) {
302
+ return {
303
+ fileName: cleanName(frame.fileName),
304
+ lineNumber: frame.lineNumber ?? null,
305
+ columnNumber: frame.columnNumber ?? null,
306
+ }
307
+ }
308
+ } catch {
309
+ return null
310
+ }
311
+
312
+ return null
313
+ }
314
+
315
+ let fiber: BippyFiber | null = null
316
+ try {
317
+ fiber = bippy.getFiberFromHostInstance(el)
318
+ } catch {
319
+ return null
320
+ }
321
+
322
+ if (!fiber) {
323
+ return null
324
+ }
325
+
326
+ const componentFibers: BippyFiber[] = []
327
+ let current: BippyFiber | null | undefined = fiber
328
+ while (current && componentFibers.length < 20) {
329
+ try {
330
+ if (bippy.isCompositeFiber(current)) {
331
+ componentFibers.push(current)
332
+ }
333
+ } catch {
334
+ // Ignore malformed or unsupported fibers and keep walking upward.
335
+ }
336
+ current = current.return
337
+ }
338
+
339
+ if (componentFibers.length === 0) {
340
+ return null
88
341
  }
89
- return null
342
+
343
+ const hierarchy = await Promise.all(
344
+ componentFibers.map(async (componentFiber): Promise<ReactComponentHierarchyItem> => {
345
+ const componentName = (() => {
346
+ try {
347
+ return componentFiber.type ? bippy.getDisplayName(componentFiber.type) : null
348
+ } catch {
349
+ return null
350
+ }
351
+ })()
352
+
353
+ return {
354
+ componentName,
355
+ source: await getSourceForFiber(componentFiber),
356
+ props: serializeReactValue(componentFiber.memoizedProps, { depth: 0, seen: new WeakSet<object>() }),
357
+ }
358
+ }),
359
+ )
360
+
361
+ const nearest = hierarchy[0]
362
+ if (!nearest) {
363
+ return null
364
+ }
365
+
366
+ return {
367
+ componentName: nearest.componentName,
368
+ source: nearest.source,
369
+ hierarchy,
370
+ props: nearest.props,
371
+ }
372
+ }
373
+
374
+ if ('page' in locator) {
375
+ return await locator.evaluate(evaluateReactComponentInfo)
90
376
  }
91
377
 
92
- return result
378
+ return await locator.evaluate(evaluateReactComponentInfo)
93
379
  }
@@ -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()
@@ -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:
@@ -209,6 +219,7 @@ You can collaborate with the user - they can help with captchas, difficult eleme
209
219
  - **Wait for load**: use `state.page.waitForLoadState('domcontentloaded')` not `state.page.waitForEvent('load')` - waitForEvent times out if already loaded
210
220
  - **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
221
  - **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
222
+ - **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
223
  - **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
224
 
214
225
  ## interaction feedback loop
@@ -601,7 +612,7 @@ Instead, use simpler alternatives (single download via `a.click()`, store data i
601
612
 
602
613
  ```js
603
614
  const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')])
604
- await download.saveAs(`/tmp/${download.suggestedFilename()}`)
615
+ await download.saveAs(`/absolute/path/${download.suggestedFilename()}`)
605
616
  ```
606
617
 
607
618
  **iFrames** - two approaches depending on what you need:
@@ -754,6 +765,19 @@ const source = await getReactSource({ locator: state.page.locator('[data-testid=
754
765
  // => { fileName, lineNumber, columnNumber, componentName }
755
766
  ```
756
767
 
768
+ **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.
769
+
770
+ ```js
771
+ const info = await getReactComponentInfo({ locator: state.page.locator('[data-testid="submit-btn"]') })
772
+ // => { componentName, source, hierarchy, props } | null
773
+ ```
774
+
775
+ **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.
776
+
777
+ ```js
778
+ await inspectPinnedElement('https://example.com', 'globalThis.playwriterPinnedElem1')
779
+ ```
780
+
757
781
  **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
782
 
759
783
  ```js
@@ -806,7 +830,7 @@ await screenshotWithAccessibilityLabels({ page: state.page })
806
830
 
807
831
  Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs.
808
832
 
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`.
833
+ **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
834
 
811
835
  **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
836
 
@@ -815,7 +839,7 @@ For demos, use interaction methods (`locator.click()`, `page.mouse.move()`) inst
815
839
  ```js
816
840
  await recording.start({
817
841
  page: state.page,
818
- outputPath: './recording.mp4',
842
+ outputPath: '/absolute/path/to/recording.mp4',
819
843
  frameRate: 30, // default
820
844
  audio: false, // default (tab audio)
821
845
  videoBitsPerSecond: 2500000,
@@ -869,7 +893,7 @@ await el.click()
869
893
  Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:
870
894
 
871
895
  ```js
872
- await state.page.screenshot({ path: 'shot.png', scale: 'css' })
896
+ await state.page.screenshot({ path: '/absolute/path/to/shot.png', scale: 'css' })
873
897
  ```
874
898
 
875
899
  If you want to read back the image file into context, resize it first so it consumes fewer tokens:
@@ -1048,7 +1072,7 @@ await state.page.setViewportSize({ width: 1280, height: 720 })
1048
1072
  ### region screenshot (zoom equivalent)
1049
1073
 
1050
1074
  ```js
1051
- await state.page.screenshot({ path: 'region.png', scale: 'css', clip: { x: 100, y: 200, width: 400, height: 300 } })
1075
+ await state.page.screenshot({ path: '/absolute/path/to/region.png', scale: 'css', clip: { x: 100, y: 200, width: 400, height: 300 } })
1052
1076
  ```
1053
1077
 
1054
1078
  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.