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.
- package/dist/bippy.js +5 -5
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +17 -5
- package/dist/cdp-relay.js.map +1 -1
- package/dist/cli-help.test.js +22 -0
- package/dist/cli-help.test.js.map +1 -1
- package/dist/cli.js +61 -20
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +38 -1
- package/dist/executor.js.map +1 -1
- package/dist/extension/background.js +322 -52
- package/dist/extension/manifest.json +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +6 -1
- package/dist/mcp.js.map +1 -1
- package/dist/performance-examples.d.ts +5 -0
- package/dist/performance-examples.d.ts.map +1 -0
- package/dist/performance-examples.js +112 -0
- package/dist/performance-examples.js.map +1 -0
- package/dist/performance-profiling.md +417 -0
- package/dist/prompt.md +19 -5
- package/dist/react-source.d.ts +44 -0
- package/dist/react-source.d.ts.map +1 -1
- package/dist/react-source.js +207 -20
- package/dist/react-source.js.map +1 -1
- package/dist/readability.js +1 -1
- package/dist/relay-session.test.js +34 -6
- package/dist/relay-session.test.js.map +1 -1
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +19 -4
- package/dist/screen-recording.js.map +1 -1
- package/dist/selector-generator.js +1 -1
- package/package.json +3 -3
- package/src/cdp-relay.ts +17 -5
- package/src/cli-help.test.ts +22 -0
- package/src/cli.ts +66 -19
- package/src/executor.ts +47 -4
- package/src/mcp.ts +6 -1
- package/src/performance-examples.ts +186 -0
- package/src/react-source.ts +310 -24
- package/src/relay-session.test.ts +36 -10
- package/src/screen-recording.ts +20 -4
- package/src/skill.md +30 -6
package/src/react-source.ts
CHANGED
|
@@ -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
|
|
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
|
|
48
|
-
|
|
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 ?
|
|
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:
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
925
|
-
return React.createElement('button', { id: 'react-btn' },
|
|
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(
|
|
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(() => !!
|
|
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(() => !!
|
|
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 =
|
|
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 =
|
|
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('
|
|
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()
|
package/src/screen-recording.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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(`/
|
|
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: '
|
|
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: '
|
|
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.
|