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
@@ -0,0 +1,186 @@
1
+ // Example snippets for profiling website performance with Playwriter and CDP.
2
+
3
+ import { console, getCDPSession, page } from './debugger-examples-types.js'
4
+
5
+ type PerfMetrics = {
6
+ paints: Record<string, number>
7
+ lcp: number
8
+ cls: number
9
+ }
10
+
11
+ type ObservedPerfEntry = {
12
+ name: string
13
+ startTime: number
14
+ duration: number
15
+ hadRecentInput?: boolean
16
+ value?: number
17
+ interactionId?: number
18
+ }
19
+
20
+ type NavigationTimingEntry = {
21
+ responseStart: number
22
+ domContentLoadedEventEnd: number
23
+ loadEventEnd: number
24
+ }
25
+
26
+ type LongTaskEntry = {
27
+ startTime: number
28
+ duration: number
29
+ }
30
+
31
+ type EventTimingEntry = {
32
+ name: string
33
+ duration: number
34
+ interactionId: number
35
+ }
36
+
37
+ // Example: Collect navigation timing and basic web vitals from the current page
38
+ async function collectWebVitals() {
39
+ await page.evaluate(() => {
40
+ const metrics: PerfMetrics = {
41
+ paints: {},
42
+ lcp: 0,
43
+ cls: 0,
44
+ }
45
+
46
+ const perfGlobal = globalThis as typeof globalThis & {
47
+ __pwPerfMetrics?: PerfMetrics
48
+ }
49
+
50
+ perfGlobal.__pwPerfMetrics = metrics
51
+
52
+ new PerformanceObserver((list) => {
53
+ for (const entry of list.getEntries() as ObservedPerfEntry[]) {
54
+ metrics.paints[entry.name] = entry.startTime
55
+ }
56
+ }).observe({ type: 'paint', buffered: true } as never)
57
+
58
+ new PerformanceObserver((list) => {
59
+ const entries = list.getEntries() as ObservedPerfEntry[]
60
+ const lastEntry = entries[entries.length - 1]
61
+ if (!lastEntry) {
62
+ return
63
+ }
64
+ metrics.lcp = lastEntry.startTime
65
+ }).observe({ type: 'largest-contentful-paint', buffered: true } as never)
66
+
67
+ new PerformanceObserver((list) => {
68
+ for (const entry of list.getEntries() as ObservedPerfEntry[]) {
69
+ if (entry.hadRecentInput) {
70
+ continue
71
+ }
72
+ metrics.cls += entry.value || 0
73
+ }
74
+ }).observe({ type: 'layout-shift', buffered: true } as never)
75
+ })
76
+
77
+ await page.reload({ waitUntil: 'domcontentloaded' })
78
+
79
+ const report = await page.evaluate(() => {
80
+ const perfGlobal = globalThis as typeof globalThis & {
81
+ __pwPerfMetrics?: PerfMetrics
82
+ }
83
+ const nav = performance.getEntriesByType('navigation' as never)[0] as unknown as
84
+ | NavigationTimingEntry
85
+ | undefined
86
+ const metrics = perfGlobal.__pwPerfMetrics
87
+
88
+ return {
89
+ ttfb: nav?.responseStart || 0,
90
+ domContentLoaded: nav?.domContentLoadedEventEnd || 0,
91
+ load: nav?.loadEventEnd || 0,
92
+ fcp: metrics?.paints['first-contentful-paint'] || 0,
93
+ lcp: metrics?.lcp || 0,
94
+ cls: metrics?.cls || 0,
95
+ }
96
+ })
97
+
98
+ console.log(report)
99
+ }
100
+
101
+ // Example: Measure the biggest transferred requests with raw CDP network events
102
+ async function collectHeaviestRequests() {
103
+ const cdp = await getCDPSession({ page })
104
+ await cdp.send('Network.enable')
105
+ await cdp.send('Network.setCacheDisabled', { cacheDisabled: true })
106
+
107
+ const responses = new Map<string, { url: string; mimeType: string }>()
108
+ const finished = new Map<string, number>()
109
+
110
+ cdp.on('Network.responseReceived', (event) => {
111
+ responses.set(event.requestId, {
112
+ url: event.response.url,
113
+ mimeType: event.response.mimeType,
114
+ })
115
+ })
116
+
117
+ cdp.on('Network.loadingFinished', (event) => {
118
+ finished.set(event.requestId, event.encodedDataLength)
119
+ })
120
+
121
+ await page.reload({ waitUntil: 'domcontentloaded' })
122
+
123
+ const largest = [...responses.entries()]
124
+ .map(([requestId, response]) => {
125
+ return {
126
+ url: response.url,
127
+ mimeType: response.mimeType,
128
+ bytes: finished.get(requestId) || 0,
129
+ }
130
+ })
131
+ .sort((a, b) => b.bytes - a.bytes)
132
+ .slice(0, 10)
133
+
134
+ console.log(largest)
135
+ }
136
+
137
+ // Example: Check whether interactivity is blocked by long tasks or slow events
138
+ async function measureInteractivity() {
139
+ await page.evaluate(() => {
140
+ const perfGlobal = globalThis as typeof globalThis & {
141
+ __pwLongTasks?: LongTaskEntry[]
142
+ __pwEventTimings?: EventTimingEntry[]
143
+ }
144
+
145
+ perfGlobal.__pwLongTasks = []
146
+ perfGlobal.__pwEventTimings = []
147
+
148
+ new PerformanceObserver((list) => {
149
+ perfGlobal.__pwLongTasks?.push(
150
+ ...(list.getEntries() as ObservedPerfEntry[]).map((entry) => ({
151
+ startTime: entry.startTime,
152
+ duration: entry.duration,
153
+ })),
154
+ )
155
+ }).observe({ type: 'longtask', buffered: true } as never)
156
+
157
+ new PerformanceObserver((list) => {
158
+ perfGlobal.__pwEventTimings?.push(
159
+ ...(list.getEntries() as ObservedPerfEntry[]).map((entry) => ({
160
+ name: entry.name,
161
+ duration: entry.duration,
162
+ interactionId: entry.interactionId || 0,
163
+ })),
164
+ )
165
+ }).observe({ type: 'event', buffered: true, durationThreshold: 16 } as never)
166
+ })
167
+
168
+ const button = page.getByRole('button').first()
169
+ await button.click()
170
+
171
+ const report = await page.evaluate(() => {
172
+ const perfGlobal = globalThis as typeof globalThis & {
173
+ __pwLongTasks?: LongTaskEntry[]
174
+ __pwEventTimings?: EventTimingEntry[]
175
+ }
176
+
177
+ return {
178
+ longTasks: (perfGlobal.__pwLongTasks || []).filter((entry) => entry.duration >= 50),
179
+ events: (perfGlobal.__pwEventTimings || []).filter((entry) => entry.interactionId !== 0),
180
+ }
181
+ })
182
+
183
+ console.log(report)
184
+ }
185
+
186
+ export { collectWebVitals, collectHeaviestRequests, measureInteractivity }
@@ -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
  }
@@ -39,6 +39,31 @@ export async function getRelayServerVersion(port: number = RELAY_PORT): Promise<
39
39
  }
40
40
  }
41
41
 
42
+ /**
43
+ * Poll /version until a relay responds or timeout expires.
44
+ * Used during startup races where a relay may have bound the port
45
+ * but isn't serving HTTP yet (issue #75).
46
+ */
47
+ export async function waitForRelayVersion({
48
+ port = RELAY_PORT,
49
+ timeoutMs = 2000,
50
+ intervalMs = 200,
51
+ }: {
52
+ port?: number
53
+ timeoutMs?: number
54
+ intervalMs?: number
55
+ } = {}): Promise<string | null> {
56
+ const end = Date.now() + timeoutMs
57
+ while (Date.now() < end) {
58
+ const version = await getRelayServerVersion(port)
59
+ if (version) {
60
+ return version
61
+ }
62
+ await sleep(intervalMs)
63
+ }
64
+ return null
65
+ }
66
+
42
67
  export async function getExtensionStatus(
43
68
  port: number = RELAY_PORT,
44
69
  ): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null } | null> {
@@ -196,11 +221,26 @@ export interface EnsureRelayServerOptions {
196
221
  env?: Record<string, string>
197
222
  }
198
223
 
224
+ // Module-level dedup: if ensureRelayServer is called concurrently within the
225
+ // same process (e.g. two MCP tool handlers at once), only one spawn runs.
226
+ let pendingEnsure: Promise<true | undefined> | null = null
227
+
199
228
  /**
200
229
  * Ensures the relay server is running. Starts it if not running.
201
230
  * Optionally restarts on version mismatch.
231
+ * Concurrent calls within the same process are deduplicated.
202
232
  */
203
233
  export async function ensureRelayServer(options: EnsureRelayServerOptions = {}): Promise<true | undefined> {
234
+ if (pendingEnsure) {
235
+ return pendingEnsure
236
+ }
237
+ pendingEnsure = ensureRelayServerImpl(options).finally(() => {
238
+ pendingEnsure = null
239
+ })
240
+ return pendingEnsure
241
+ }
242
+
243
+ async function ensureRelayServerImpl(options: EnsureRelayServerOptions = {}): Promise<true | undefined> {
204
244
  const { logger, restartOnVersionMismatch = true, env: additionalEnv } = options
205
245
  const serverVersion = await getRelayServerVersion(RELAY_PORT)
206
246
 
@@ -227,11 +267,28 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}):
227
267
  } else {
228
268
  const listeningPids = await getListeningPidsForPort({ port: RELAY_PORT }).catch(() => [])
229
269
  if (listeningPids.length > 0) {
230
- logger?.log(
231
- pc.yellow(
232
- `Port ${RELAY_PORT} is already in use (pid(s): ${listeningPids.join(', ')}). Attempting to stop the existing process...`,
233
- ),
234
- )
270
+ // Something is on the port but /version didn't respond. It might be a
271
+ // relay that's still starting (race with another CLI/MCP instance).
272
+ // Poll /version briefly before deciding to kill it (issue #75).
273
+ const foundVersion = await waitForRelayVersion({ port: RELAY_PORT })
274
+ if (foundVersion) {
275
+ // A relay came up while we waited; use it
276
+ if (foundVersion === VERSION || compareVersions(foundVersion, VERSION) > 0) {
277
+ return
278
+ }
279
+ if (!restartOnVersionMismatch) {
280
+ return
281
+ }
282
+ logger?.log(
283
+ pc.yellow(`CDP relay server version mismatch (server: ${foundVersion}, client: ${VERSION}), restarting...`),
284
+ )
285
+ } else {
286
+ logger?.log(
287
+ pc.yellow(
288
+ `Port ${RELAY_PORT} is already in use (pid(s): ${listeningPids.join(', ')}). Attempting to stop the existing process...`,
289
+ ),
290
+ )
291
+ }
235
292
  await killRelayServer({ port: RELAY_PORT })
236
293
  }
237
294