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.
- package/dist/bippy.js +5 -5
- package/dist/cdp-log.d.ts +4 -1
- package/dist/cdp-log.d.ts.map +1 -1
- package/dist/cdp-log.js +39 -2
- package/dist/cdp-log.js.map +1 -1
- package/dist/cdp-log.test.d.ts +2 -0
- package/dist/cdp-log.test.d.ts.map +1 -0
- package/dist/cdp-log.test.js +109 -0
- package/dist/cdp-log.test.js.map +1 -0
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +120 -11
- 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 +69 -25
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts +4 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +140 -33
- package/dist/executor.js.map +1 -1
- package/dist/extension/background.js +343 -62
- 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 +51 -18
- 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-client.d.ts +11 -0
- package/dist/relay-client.d.ts.map +1 -1
- package/dist/relay-client.js +46 -1
- package/dist/relay-client.js.map +1 -1
- package/dist/relay-core.test.js +10 -6
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-session.test.js +43 -7
- package/dist/relay-session.test.js.map +1 -1
- package/dist/relay-state.test.js +57 -1
- package/dist/relay-state.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/dist/start-relay-server.d.ts +1 -1
- package/dist/start-relay-server.d.ts.map +1 -1
- package/dist/start-relay-server.js +23 -1
- package/dist/start-relay-server.js.map +1 -1
- package/dist/utils.d.ts +2 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +4 -1
- package/dist/utils.js.map +1 -1
- package/package.json +3 -3
- package/src/cdp-log.test.ts +131 -0
- package/src/cdp-log.ts +44 -2
- package/src/cdp-relay.ts +127 -10
- package/src/cli-help.test.ts +22 -0
- package/src/cli.ts +74 -24
- package/src/executor.ts +166 -39
- package/src/mcp.ts +6 -1
- package/src/performance-examples.ts +186 -0
- package/src/react-source.ts +310 -24
- package/src/relay-client.ts +62 -5
- package/src/relay-core.test.ts +10 -6
- package/src/relay-session.test.ts +45 -11
- package/src/relay-state.test.ts +67 -1
- package/src/screen-recording.ts +20 -4
- package/src/skill.md +62 -19
- package/src/start-relay-server.ts +22 -1
- 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 }
|
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
|
}
|
package/src/relay-client.ts
CHANGED
|
@@ -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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|