File size: 6,136 Bytes
2b2ba56 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | // The one Chrome DevTools Protocol client for the desktop perf harness.
//
// Before this, every measure-*/profile-* script shipped its own copy-pasted
// `CDP` class (four subtly different implementations), its own `/json` vs
// `/json/list` target discovery, and its own Profiler ranking. Scenarios now
// import from here so there is a single place to fix a protocol bug.
const DEFAULT_PORT = 9222
// Stable DOM hooks the renderer exposes. Centralised so a component refactor
// updates one constant instead of a dozen scattered querySelector strings.
export const SELECTORS = {
composer: '[data-slot="composer-rich-input"]',
threadViewport: '[data-slot="aui_thread-viewport"]',
threadContent: '[data-slot="aui_thread-content"]',
assistantMessage: '[data-slot="aui_assistant-message-root"]',
turnPair: '[data-slot="aui_turn-pair"]',
profileRail: '[data-slot="profile-rail"]',
rowButton: '[data-slot="row-button"]'
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
/**
* Poll the CDP HTTP endpoint until a page target is available.
* @param {object} [opts]
* @param {number} [opts.port] remote-debugging-port (default 9222).
* @param {string} [opts.match] substring the target URL must contain (e.g. a dev-server port).
* @param {number} [opts.timeoutMs] how long to wait for a target.
*/
export async function discoverTarget({ port = DEFAULT_PORT, match, timeoutMs = 30000 } = {}) {
const deadline = Date.now() + timeoutMs
for (;;) {
try {
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const pages = list.filter(t => t.type === 'page' && typeof t.webSocketDebuggerUrl === 'string')
const target = match
? pages.find(t => String(t.url).includes(match))
: pages.find(t => String(t.url).startsWith('http')) ?? pages[0]
if (target) {
return target
}
} catch {
// debug port not up yet — keep polling until the deadline.
}
if (Date.now() >= deadline) {
throw new Error(`no CDP page target on :${port}${match ? ` matching "${match}"` : ''} within ${timeoutMs}ms`)
}
await sleep(250)
}
}
export class CDP {
constructor(ws) {
this.ws = ws
this.id = 0
this.pending = new Map()
this.listeners = new Map()
}
static async open(url) {
const ws = new WebSocket(url)
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, { once: true })
ws.addEventListener('error', reject, { once: true })
})
const cdp = new CDP(ws)
ws.addEventListener('message', ev => {
const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8'))
if (m.id != null && cdp.pending.has(m.id)) {
const { resolve, reject } = cdp.pending.get(m.id)
cdp.pending.delete(m.id)
if (m.error) {
reject(new Error(m.error.message))
} else {
resolve(m.result)
}
} else if (m.method) {
for (const handler of cdp.listeners.get(m.method) ?? []) {
handler(m.params)
}
}
})
ws.addEventListener('close', () => {
for (const { reject } of cdp.pending.values()) {
reject(new Error('CDP socket closed'))
}
cdp.pending.clear()
})
return cdp
}
/** Connect straight to a discovered target. */
static async connect(opts) {
const target = await discoverTarget(opts)
return CDP.open(target.webSocketDebuggerUrl)
}
send(method, params = {}) {
const id = ++this.id
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
this.ws.send(JSON.stringify({ id, method, params }))
})
}
on(method, handler) {
if (!this.listeners.has(method)) {
this.listeners.set(method, [])
}
this.listeners.get(method).push(handler)
}
/** Evaluate an expression in the page and return its value (awaits promises). */
async eval(expression) {
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
if (r.exceptionDetails) {
throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text || 'eval failed')
}
return r.result.value
}
close() {
this.ws.close()
}
}
/** Assert the renderer has the dev-only `__PERF_DRIVE__` harness attached. */
export async function requireDriver(cdp) {
const ok = await cdp.eval('!!(window.__PERF_DRIVE__ && window.__PERF_DRIVE__.stream)')
if (!ok) {
throw new Error(
'__PERF_DRIVE__ not on window. The perf harness needs a DEV renderer ' +
'(perf-probe.tsx is excluded from production builds). Launch with `npm run perf:serve`.'
)
}
}
/** Type real key events into the composer, one char at a time, at `cps` chars/sec. */
export async function typeIntoComposer(cdp, text, { cps = 15 } = {}) {
await cdp.eval(`(() => {
const el = document.querySelector(${JSON.stringify(SELECTORS.composer)})
if (!el) return false
el.focus()
const range = document.createRange()
range.selectNodeContents(el)
range.collapse(false)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
return true
})()`)
const intervalMs = Math.max(1, Math.round(1000 / cps))
for (const ch of text) {
await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: ch, unmodifiedText: ch })
await sleep(intervalMs)
}
}
/**
* Run `body()` while a V8 CPU profile is recording. Returns
* `{ result, profile }`; the caller decides whether to write the .cpuprofile.
*/
export async function withCpuProfile(cdp, body, { samplingIntervalUs = 100 } = {}) {
await cdp.send('Profiler.enable')
await cdp.send('Profiler.setSamplingInterval', { interval: samplingIntervalUs })
await cdp.send('Profiler.start')
let result
let stopped
try {
result = await body()
} finally {
// Always stop so a scenario error can't leave the profiler running.
stopped = await cdp.send('Profiler.stop')
}
return { result, profile: stopped.profile }
}
export { sleep }
|