From f7ad9a8ad605036924c5406ef9b54ee6c10d8979 Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Wed, 26 Aug 2026 03:22:24 -0700 Subject: [PATCH] feat(debugger): add bounded web performance capture API --- npm_modules/cli/debugger/README.md | 11 + npm_modules/cli/src/debugger/server.spec.ts | 651 ++++++++++- npm_modules/cli/src/debugger/server.ts | 229 +++- .../debugger/webPreviewPerformance.spec.ts | 392 +++++++ .../cli/src/debugger/webPreviewPerformance.ts | 1021 +++++++++++++++++ .../cli/src/utils/chromiumDevToolsClient.ts | 13 +- 6 files changed, 2271 insertions(+), 46 deletions(-) create mode 100644 npm_modules/cli/src/debugger/webPreviewPerformance.spec.ts create mode 100644 npm_modules/cli/src/debugger/webPreviewPerformance.ts diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md index 649e6003..a14f0527 100644 --- a/npm_modules/cli/debugger/README.md +++ b/npm_modules/cli/debugger/README.md @@ -45,6 +45,7 @@ Important routes: - `/api/performance/profile/*`: list Hermes contexts and capture CPU profiles. - `/api/devtools/target`: matches the inspected Chromium page to the exact configured preview origin and path. - `/api/devtools/snapshot`, `/api/devtools/highlight`, and `/api/devtools/evaluate`: proxy the explicit web debugger bridge contract through loopback CDP. +- `/api/devtools/performance/snapshot` and `/api/devtools/performance/trace/*`: sample the exact web preview and record one bounded global Chromium trace without changing the daemon/Hermes `/api/performance/*` routes. Renderer tracing uses the runtime debugger protocol and the existing native trace recorder. Captures are process-wide: the selected context is the capture @@ -62,6 +63,16 @@ Web-renderer inspection uses the first-party bridge exposed as `clearHighlight()`. The DevTools panel proxies inspection through the exact configured loopback Chromium target. +Web-preview performance requests require the exact `sessionId`, `inspectedUrl`, +and per-tab `targetNonce`; incomplete, stale, or cross-tab identities fail +closed. The recorder normalizes CDP events incrementally and retains at most +10,000 events with 2 KiB UTF-8 trace names. One-shot captures run for their +requested duration from 100 milliseconds through 15 seconds; manually started +recordings have a 15-second watchdog. An undelivered completed result is kept +for one minute. The complete response is limited +to 4 MiB and contains one normalized trace list plus export metadata—never a +duplicate raw or Perfetto event list. + The Data section discovers target-owned providers through a generic custom message contract. The persistence module registers its bounded web snapshot as the `persistent-store` Storage provider and reports it unavailable on platforms diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts index dc64505e..5e7045bc 100644 --- a/npm_modules/cli/src/debugger/server.spec.ts +++ b/npm_modules/cli/src/debugger/server.spec.ts @@ -55,10 +55,16 @@ interface MockChromiumConsoleServer { port: number; runtimeEnableReceived: Promise; releaseRuntimeEnable(): void; + setInspectedUrl(inspectedUrl: string): void; + setTargetNonce(targetNonce: string): void; } interface MockChromiumConsoleServerOptions { + closeOnTracingEnd?: boolean; + dropTracingStartResponse?: boolean; holdRuntimeEnable: boolean; + rejectIdentityAfterTracingStart?: boolean; + rejectTracingStart?: boolean; } function encodeChromiumServerMessage(payload: Record): Buffer { @@ -111,6 +117,8 @@ async function startMockChromiumConsoleServer( const debuggerSockets = new Set(); const pendingRuntimeEnableResponses: Array<() => void> = []; const methods: string[] = []; + let currentInspectedUrl = `${applicationUrl}${applicationUrl.includes('?') ? '&' : '?'}valdiDevTools=1`; + let currentTargetNonce = targetNonce; let resolveRuntimeEnableReceived: (() => void) | null = null; const runtimeEnableReceived = new Promise(resolve => { resolveRuntimeEnableReceived = resolve; @@ -132,7 +140,7 @@ async function startMockChromiumConsoleServer( id: 'selected-page', title: 'Selected Valdi page', type: 'page', - url: `${applicationUrl}${applicationUrl.includes('?') ? '&' : '?'}valdiDevTools=1`, + url: currentInspectedUrl, webSocketDebuggerUrl: `ws://127.0.0.1:${address.port}/devtools/page/selected-page`, }, ]), @@ -151,6 +159,8 @@ async function startMockChromiumConsoleServer( debuggerSockets.add(socket); socket.once('end', () => socket.destroy()); socket.once('close', () => debuggerSockets.delete(socket)); + let metricRequestCount = 0; + let tracingStarted = false; let buffered = Buffer.alloc(0); socket.on('data', chunk => { buffered = Buffer.concat([buffered, chunk]); @@ -165,60 +175,148 @@ async function startMockChromiumConsoleServer( return; } methods.push(method); - if (method === 'Runtime.evaluate') { - const expression = typeof params?.['expression'] === 'string' ? params['expression'] : ''; - const matched = expression.includes(applicationUrl) && expression.includes(targetNonce); - socket.write( - encodeChromiumServerMessage({ - id, - result: { + switch (method) { + case 'Runtime.evaluate': { + const expression = typeof params?.['expression'] === 'string' ? params['expression'] : ''; + const guarded = expression.includes('__valdiDevToolsTargetMatched'); + const matched = + guarded && + expression.includes(applicationUrl) && + expression.includes(currentTargetNonce) && + !(options.rejectIdentityAfterTracingStart === true && tracingStarted); + let value: unknown; + if (guarded) { + value = { __valdiDevToolsTargetMatched: matched, ...(matched ? { value: true } : {}) }; + } else if (expression === 'String(globalThis.location.href)') { + value = currentInspectedUrl; + } else if (expression.includes("getEntriesByType('resource')")) { + value = { + navigation: { domContentLoadedMs: 30, loadMs: 50 }, + paints: [{ name: 'first-contentful-paint', startTime: 25 }], + rendererTracingEnabled: false, + resourceCount: 4, + transferSize: 2048, + uptimeMs: 100, + }; + } else { + value = true; + } + socket.write( + encodeChromiumServerMessage({ + id, result: { - type: 'object', - value: { __valdiDevToolsTargetMatched: matched, ...(matched ? { value: true } : {}) }, + result: { + type: typeof value, + value, + }, }, - }, - }), - ); - } else { - const sendResponse = () => { - if (socket.destroyed) return; - socket.write(encodeChromiumServerMessage({ id, result: {} })); - if (method === 'Runtime.enable') { - socket.write( + }), + ); + break; + } + case 'Performance.getMetrics': { + metricRequestCount++; + socket.write( + encodeChromiumServerMessage({ + id, + result: { + metrics: [ + { name: 'TaskDuration', value: metricRequestCount * 0.012 }, + { name: 'ScriptDuration', value: metricRequestCount * 0.004 }, + { name: 'LayoutDuration', value: metricRequestCount * 0.002 }, + { name: 'LayoutCount', value: metricRequestCount * 2 }, + { name: 'RecalcStyleCount', value: metricRequestCount }, + { name: 'JSHeapUsedSize', value: 1024 }, + { name: 'JSHeapTotalSize', value: 2048 }, + ], + }, + }), + ); + break; + } + case 'Tracing.end': { + if (options.closeOnTracingEnd) { + socket.destroy(); + break; + } + socket.write( + Buffer.concat([ + encodeChromiumServerMessage({ id, result: {} }), encodeChromiumServerMessage({ - method: 'Runtime.consoleAPICalled', + method: 'Tracing.dataCollected', params: { - args: [{ type: 'string', value: 'Synthetic output' }], - timestamp: 101, - type: 'warning', + value: [ + { name: 'Valdi.Renderer.onRender.Example', ph: 'X', ts: 1000, dur: 300, tid: 7 }, + { name: 'Layout', ph: 'X', ts: 1400, dur: 500, tid: 7 }, + { name: 'RunTask', ph: 'X', ts: 2000, dur: 75_000, tid: 7 }, + { name: 'Unrelated', ph: 'X', ts: 3000, dur: 400, tid: 7 }, + ], }, }), - ); - } - if (method === 'Log.enable') { + encodeChromiumServerMessage({ + method: 'Tracing.tracingComplete', + params: { dataLossOccurred: false }, + }), + ]), + ); + break; + } + default: { + if (method === 'Tracing.start' && options.rejectTracingStart) { socket.write( encodeChromiumServerMessage({ - method: 'Log.entryAdded', - params: { - entry: { - level: 'error', - text: 'authorization: Bearer synthetic-private-token', - timestamp: 102, - }, - }, + error: { message: 'Tracing is already started by another client.' }, + id, }), ); + break; + } + if (method === 'Tracing.start') tracingStarted = true; + if (method === 'Tracing.start' && options.dropTracingStartResponse) { + socket.destroy(); + break; } - }; - if (method === 'Runtime.enable') { - resolveRuntimeEnableReceived?.(); - if (options.holdRuntimeEnable) { - pendingRuntimeEnableResponses.push(sendResponse); + const sendResponse = () => { + if (socket.destroyed) return; + socket.write(encodeChromiumServerMessage({ id, result: {} })); + if (method === 'Runtime.enable') { + socket.write( + encodeChromiumServerMessage({ + method: 'Runtime.consoleAPICalled', + params: { + args: [{ type: 'string', value: 'Synthetic output' }], + timestamp: 101, + type: 'warning', + }, + }), + ); + } + if (method === 'Log.enable') { + socket.write( + encodeChromiumServerMessage({ + method: 'Log.entryAdded', + params: { + entry: { + level: 'error', + text: 'authorization: Bearer synthetic-private-token', + timestamp: 102, + }, + }, + }), + ); + } + }; + if (method === 'Runtime.enable') { + resolveRuntimeEnableReceived?.(); + if (options.holdRuntimeEnable) { + pendingRuntimeEnableResponses.push(sendResponse); + } else { + sendResponse(); + } } else { sendResponse(); } - } else { - sendResponse(); + break; } } frame = readChromiumClientFrame(buffered); @@ -244,6 +342,12 @@ async function startMockChromiumConsoleServer( for (const sendResponse of pendingRuntimeEnableResponses.splice(0)) sendResponse(); }, runtimeEnableReceived, + setInspectedUrl(nextInspectedUrl: string): void { + currentInspectedUrl = nextInspectedUrl; + }, + setTargetNonce(nextTargetNonce: string): void { + currentTargetNonce = nextTargetNonce; + }, }; } @@ -789,6 +893,460 @@ describe('debugger server', () => { }); }); + it('requires the exact session, inspected URL, and nonce for web preview performance routes', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html?tenant=alpha'; + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: 9333, + }); + const statusUrl = new URL('/api/devtools/performance/trace/status', debuggerServer.url); + statusUrl.searchParams.set('inspectedUrl', `${applicationUrl}&valdiDevTools=1`); + statusUrl.searchParams.set('sessionId', 'owl:web-preview'); + statusUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + const aliasedSession = await request(statusUrl.toString(), GET_REQUEST_OPTIONS); + statusUrl.searchParams.set('sessionId', 'web-preview'); + statusUrl.searchParams.set('inspectedUrl', 'http://127.0.0.1:54321/other.html'); + const wrongUrl = await request(statusUrl.toString(), GET_REQUEST_OPTIONS); + statusUrl.searchParams.set('inspectedUrl', `${applicationUrl}&valdiDevTools=1`); + statusUrl.searchParams.delete('targetNonce'); + const missingNonce = await request(statusUrl.toString(), GET_REQUEST_OPTIONS); + const duplicateResults: HttpResult[] = []; + for (const [name, value] of [ + ['sessionId', 'conflicting-session'], + ['inspectedUrl', 'http://127.0.0.1:54321/other.html'], + ['targetNonce', 'conflicting-nonce-123456'], + ] as const) { + const duplicateUrl = new URL('/api/devtools/performance/trace/status', debuggerServer.url); + duplicateUrl.searchParams.append('sessionId', 'web-preview'); + duplicateUrl.searchParams.append('inspectedUrl', `${applicationUrl}&valdiDevTools=1`); + duplicateUrl.searchParams.append('targetNonce', WEB_PREVIEW_NONCE); + duplicateUrl.searchParams.append(name, value); + duplicateResults.push(await request(duplicateUrl.toString(), GET_REQUEST_OPTIONS)); + } + + expect(aliasedSession.statusCode).toBe(404); + expect(JSON.parse(aliasedSession.body)).toEqual({ + error: 'The inspected web preview session is no longer available.', + }); + expect(wrongUrl.statusCode).toBe(404); + expect(JSON.parse(wrongUrl.body)).toEqual({ + error: 'The inspected page does not match the configured Valdi web preview target.', + }); + expect(missingNonce.statusCode).toBe(400); + expect(JSON.parse(missingNonce.body)).toEqual({ + error: 'targetNonce must appear exactly once for web preview performance requests.', + }); + expect(duplicateResults.map(result => result.statusCode)).toEqual([400, 400, 400]); + expect(duplicateResults.map(result => (JSON.parse(result.body) as { error: string }).error)).toEqual([ + 'sessionId must appear exactly once for web preview performance requests.', + 'inspectedUrl must appear exactly once for web preview performance requests.', + 'targetNonce must appear exactly once for web preview performance requests.', + ]); + }); + + it('serves bounded Chromium snapshots and traces on the isolated web preview routes', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const inspectedUrl = `${applicationUrl}?valdiDevTools=1`; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: false, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + const route = (pathName: string): URL => { + const url = new URL(pathName, debuggerServer?.url); + url.searchParams.set('inspectedUrl', inspectedUrl); + url.searchParams.set('sessionId', 'web-preview'); + url.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + return url; + }; + + const snapshot = await request(route('/api/devtools/performance/snapshot').toString(), GET_REQUEST_OPTIONS); + const captureResult = await request(route('/api/devtools/performance/trace/capture').toString(), { + body: JSON.stringify({ durationMs: 100 }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const capture = JSON.parse(captureResult.body) as Record; + const traces = capture['traces'] as Array>; + + expect(snapshot.statusCode).toBe(200); + expect(JSON.parse(snapshot.body)).toEqual( + jasmine.objectContaining({ + mainThread: jasmine.objectContaining({ taskDurationMs: 12 }), + memory: { totalBytes: 2048, usedBytes: 1024 }, + resourceCount: 4, + transferSize: 2048, + }), + ); + expect(captureResult.statusCode).toBe(200); + expect(Buffer.byteLength(captureResult.body, 'utf8')).toBeLessThanOrEqual(MAX_TRACE_HTTP_RESPONSE_BYTES); + expect(traces.map(trace => trace['trace'])).toEqual([ + 'Valdi.Renderer.onRender.Example', + 'Browser.Layout.Layout', + 'Browser.MainThread.Task', + ]); + expect(capture['browserMetrics']).toEqual(jasmine.objectContaining({ LayoutCount: 2, TaskDurationMs: 12 })); + expect(capture['browserSummary']).toEqual( + jasmine.objectContaining({ browserEventCount: 2, longTaskCount: 1, rendererEventCount: 1 }), + ); + expect(capture['rawTraceEvents']).toBeUndefined(); + expect(capture['perfetto']).toBeUndefined(); + expect(capture['perfettoMetadata']).toEqual(jasmine.any(Object)); + expect(chromium.methods).toContain('Tracing.start'); + expect(chromium.methods).toContain('Tracing.end'); + } finally { + await chromium.close(); + } + }); + + it('does not let a second same-URL tab steal a live performance trace', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const inspectedUrl = `${applicationUrl}?valdiDevTools=1`; + const competingNonce = 'server-competing-nonce-654321'; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: false, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + const route = (pathName: string, targetNonce: string): URL => { + const url = new URL(pathName, debuggerServer?.url); + url.searchParams.set('inspectedUrl', inspectedUrl); + url.searchParams.set('sessionId', 'web-preview'); + url.searchParams.set('targetNonce', targetNonce); + return url; + }; + const post = (url: URL): Promise => + request(url.toString(), { + body: '{}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + const started = await post(route('/api/devtools/performance/trace/start', WEB_PREVIEW_NONCE)); + const competing = await post(route('/api/devtools/performance/trace/start', competingNonce)); + + expect(started.statusCode).toBe(200); + expect(competing.statusCode).toBe(500); + expect((JSON.parse(competing.body) as { error: string }).error).toContain( + 'Another inspected web preview owns the current Chromium performance trace.', + ); + expect(chromium.methods.filter(method => method === 'Tracing.start').length).toBe(1); + expect(chromium.methods).not.toContain('Tracing.end'); + await post(route('/api/devtools/performance/trace/stop', WEB_PREVIEW_NONCE)); + } finally { + await chromium.close(); + } + }); + + it('ends an old nonce owner before starting after a verified web-preview reload', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const inspectedUrl = `${applicationUrl}?valdiDevTools=1`; + const reloadedNonce = 'server-reloaded-nonce-654321'; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: false, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + const route = (pathName: string, targetNonce: string): URL => { + const url = new URL(pathName, debuggerServer?.url); + url.searchParams.set('inspectedUrl', inspectedUrl); + url.searchParams.set('sessionId', 'web-preview'); + url.searchParams.set('targetNonce', targetNonce); + return url; + }; + const post = (url: URL): Promise => + request(url.toString(), { + body: '{}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + const initialStart = await post(route('/api/devtools/performance/trace/start', WEB_PREVIEW_NONCE)); + expect(initialStart.statusCode).toBe(200); + chromium.setTargetNonce(reloadedNonce); + const reloadedStart = await post(route('/api/devtools/performance/trace/start', reloadedNonce)); + expect(reloadedStart.statusCode).toBe(200); + + const startIndexes = chromium.methods + .map((method, index) => (method === 'Tracing.start' ? index : -1)) + .filter(index => index >= 0); + const endIndex = chromium.methods.indexOf('Tracing.end'); + expect(startIndexes.length).toBe(2); + expect(endIndex).toBeGreaterThan(startIndexes[0] ?? -1); + expect(startIndexes[1]).toBeGreaterThan(endIndex); + await post(route('/api/devtools/performance/trace/stop', reloadedNonce)); + } finally { + await chromium.close(); + } + }); + + it('recovers when the same nonce navigates away from the owner inspected URL', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const oldInspectedUrl = `${applicationUrl}?valdiDevTools=1#old`; + const newInspectedUrl = `${applicationUrl}?valdiDevTools=1#new`; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: false, + }); + chromium.setInspectedUrl(oldInspectedUrl); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + const route = (pathName: string, inspectedPageUrl: string): URL => { + const url = new URL(pathName, debuggerServer?.url); + url.searchParams.set('inspectedUrl', inspectedPageUrl); + url.searchParams.set('sessionId', 'web-preview'); + url.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + return url; + }; + const post = (url: URL): Promise => + request(url.toString(), { + body: '{}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + const initialStart = await post(route('/api/devtools/performance/trace/start', oldInspectedUrl)); + expect(initialStart.statusCode).toBe(200); + chromium.setInspectedUrl(newInspectedUrl); + const navigatedStart = await post(route('/api/devtools/performance/trace/start', newInspectedUrl)); + expect(navigatedStart.statusCode).toBe(200); + + expect(chromium.methods.filter(method => method === 'Tracing.start').length).toBe(2); + expect(chromium.methods).toContain('Tracing.end'); + await post(route('/api/devtools/performance/trace/stop', newInspectedUrl)); + } finally { + await chromium.close(); + } + }); + + it('retains fail-closed ownership when a Tracing.start response is lost', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + dropTracingStartResponse: true, + holdRuntimeEnable: false, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + const startUrl = new URL('/api/devtools/performance/trace/start', debuggerServer.url); + startUrl.searchParams.set('inspectedUrl', `${applicationUrl}?valdiDevTools=1`); + startUrl.searchParams.set('sessionId', 'web-preview'); + startUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + const post = (): Promise => + request(startUrl.toString(), { + body: '{}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + const first = await post(); + const second = await post(); + + expect(first.statusCode).toBe(500); + expect(second.statusCode).toBe(500); + expect((JSON.parse(second.body) as { error: string }).error).toContain( + 'Best-effort Chromium trace cleanup also failed', + ); + expect(chromium.methods.filter(method => method === 'Tracing.start').length).toBe(1); + } finally { + await chromium.close(); + } + }); + + it('does not end a trace after Chromium definitively rejects Tracing.start', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: false, + rejectTracingStart: true, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + const startUrl = new URL('/api/devtools/performance/trace/start', debuggerServer.url); + startUrl.searchParams.set('inspectedUrl', `${applicationUrl}?valdiDevTools=1`); + startUrl.searchParams.set('sessionId', 'web-preview'); + startUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + + const result = await request(startUrl.toString(), { + body: '{}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + expect(result.statusCode).toBe(500); + expect((JSON.parse(result.body) as { error: string }).error).toContain( + 'Tracing is already started by another client.', + ); + expect(chromium.methods.filter(method => method === 'Tracing.start').length).toBe(1); + expect(chromium.methods).not.toContain('Tracing.end'); + } finally { + await chromium.close(); + } + }); + + it('handles a socket close during Tracing.end without an unhandled rejection', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + closeOnTracingEnd: true, + holdRuntimeEnable: false, + }); + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown): void => { + unhandledRejections.push(reason); + }; + process.on('unhandledRejection', onUnhandledRejection); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + const route = (pathName: string): URL => { + const url = new URL(pathName, debuggerServer?.url); + url.searchParams.set('inspectedUrl', `${applicationUrl}?valdiDevTools=1`); + url.searchParams.set('sessionId', 'web-preview'); + url.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + return url; + }; + const post = (url: URL): Promise => + request(url.toString(), { + body: '{}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + const started = await post(route('/api/devtools/performance/trace/start')); + const stopped = await post(route('/api/devtools/performance/trace/stop')); + expect(started.statusCode).toBe(200); + expect(stopped.statusCode).toBe(500); + await new Promise(resolve => setImmediate(resolve)); + expect(unhandledRejections).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + await chromium.close(); + } + }); + + it('ends an owned trace before rejecting a target identity that changed during capture', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: false, + rejectIdentityAfterTracingStart: true, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + const captureUrl = new URL('/api/devtools/performance/trace/capture', debuggerServer.url); + captureUrl.searchParams.set('inspectedUrl', `${applicationUrl}?valdiDevTools=1`); + captureUrl.searchParams.set('sessionId', 'web-preview'); + captureUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + + const result = await request(captureUrl.toString(), { + body: JSON.stringify({ durationMs: 100 }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + expect(result.statusCode).toBe(500); + expect((JSON.parse(result.body) as { error: string }).error).toContain( + 'inspected web preview changed while the performance request was running', + ); + const endIndex = chromium.methods.lastIndexOf('Tracing.end'); + const validationIndex = chromium.methods.lastIndexOf('Runtime.evaluate'); + expect(endIndex).toBeGreaterThan(-1); + expect(validationIndex).toBeGreaterThan(endIndex); + } finally { + await chromium.close(); + } + }); + + it('enables renderer trace markers only through the exact web preview performance route', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: false, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + const enableUrl = new URL('/api/devtools/performance/trace/enable', debuggerServer.url); + enableUrl.searchParams.set('inspectedUrl', `${applicationUrl}?valdiDevTools=1`); + enableUrl.searchParams.set('sessionId', 'web-preview'); + enableUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + + const result = await request(enableUrl.toString(), { + body: '{}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const payload = JSON.parse(result.body) as { inspectedUrl: string }; + + expect(result.statusCode).toBe(200); + expect(payload.inspectedUrl).toContain('valdiDevTools=1'); + expect(payload.inspectedUrl).toContain('valdiTrace=chrome'); + expect(chromium.methods).toContain('Page.navigate'); + } finally { + await chromium.close(); + } + }); + it('rejects console streams that do not carry the exact selected target tuple', async () => { debuggerServer = await startDebuggerServer({ assetRoot, @@ -1855,7 +2413,7 @@ describe('debugger server', () => { {}, 0, ).traceEvents.find(event => event.name === 'Unrelated.trace'); - expect(fabricatedInstant).toEqual(jasmine.objectContaining({ ph: 'X', dur: 1 })); + expect(fabricatedInstant).toEqual(jasmine.objectContaining({ ph: 'i', s: 't' })); }); it('drops malformed renderer trace events and caps conversion work', () => { @@ -1918,6 +2476,13 @@ describe('debugger server', () => { })), droppedTraceEventCount: 0, timedOut: false, + webPreviewTrace: true, + browserMetrics: { + LayoutCount: 10, + PrivateMetric: 1, + ScriptDurationMs: 20, + TaskDurationMs: 30, + }, }, { contextId: '\0'.repeat(100_000), name: '\0'.repeat(100_000), port: 13_591 }, ); @@ -1928,6 +2493,8 @@ describe('debugger server', () => { expect(Array.isArray(result['traces'])).toBeTrue(); expect(result['traceCount'] as number).toBeLessThan(512); expect(result['traceEventLimitReached']).toBeTrue(); + expect(result['browserMetrics']).toEqual({ LayoutCount: 10, ScriptDurationMs: 20, TaskDurationMs: 30 }); + expect((result['browserMetrics'] as Record)['PrivateMetric']).toBeUndefined(); expect((result['perfettoMetadata'] as { droppedTraceEventCount: number }).droppedTraceEventCount).toBe( result['droppedTraceEventCount'] as number, ); diff --git a/npm_modules/cli/src/debugger/server.ts b/npm_modules/cli/src/debugger/server.ts index 759426f7..aa5d015f 100644 --- a/npm_modules/cli/src/debugger/server.ts +++ b/npm_modules/cli/src/debugger/server.ts @@ -27,6 +27,11 @@ import { } from '../utils/owlCdpClient'; import { type ChromiumConsoleEntry, formatChromiumConsoleEvent } from './chromiumConsole'; import { DebuggerInputType, sendDebuggerInput, validateDebuggerInputRequest } from './inputClient'; +import { + type WebPreviewPerformanceIdentity, + type WebPreviewTraceCapture, + createWebPreviewPerformanceController, +} from './webPreviewPerformance'; const DEFAULT_HOST = process.env['VALDI_DEBUGGER_HOST'] || '127.0.0.1'; const DEFAULT_PORT = Number.parseInt(process.env['VALDI_DEBUGGER_PORT'] || '8765', 10); @@ -68,6 +73,7 @@ const TRACE_CAPTURE_TARGET_STRING_KEYS = [ 'clientId', 'contextId', 'applicationId', + 'sessionId', ] as const; const DEBUGGER_PROVIDERS_IDENTIFIER = 'ValdiDebuggerProviders'; const DEBUG_SETTINGS_IDENTIFIER = 'ValdiDebuggerSettings'; @@ -115,6 +121,7 @@ export interface RecordedTrace { startMicros: number; endMicros: number; threadId: number; + type?: number; } export interface PerfettoCaptureMetadata { @@ -296,6 +303,7 @@ let activeProfileSession: ActiveProfileSession | null = null; let profileTransitionInProgress = false; let traceTransitionInProgress = false; const debuggerUiState = createDebuggerUiState(); +const webPreviewPerformanceController = createWebPreviewPerformanceController(); function getDefaultAssetRoot(): string { // The published CLI is emitted as CommonJS, so __dirname is the reliable package-relative anchor. @@ -1255,6 +1263,40 @@ function resolveInspectedWebPreviewContext( return { inspectedUrl: inspected.toString(), targetNonce }; } +function resolveWebPreviewPerformanceIdentity(searchParams: URLSearchParams): { + identity: WebPreviewPerformanceIdentity; + target: WebPreviewDebuggerTarget; +} { + const sessionId = readExactWebPreviewPerformanceParameter(searchParams, 'sessionId'); + const target = resolveWebPreviewDebuggerTarget(sessionId); + if (sessionId !== target.sessionId) { + throw new ApiRequestError(404, 'The inspected web preview session is no longer available.'); + } + const context = resolveInspectedWebPreviewContext( + target, + readExactWebPreviewPerformanceParameter(searchParams, 'inspectedUrl'), + readExactWebPreviewPerformanceParameter(searchParams, 'targetNonce'), + ); + return { + identity: { + applicationUrl: target.applicationUrl, + debuggingPort: target.debuggingPort, + inspectedUrl: context.inspectedUrl, + sessionId: target.sessionId, + targetNonce: context.targetNonce, + }, + target, + }; +} + +function readExactWebPreviewPerformanceParameter(searchParams: URLSearchParams, name: string): string { + const values = searchParams.getAll(name); + if (values.length !== 1 || !values[0]) { + throw new ApiRequestError(400, `${name} must appear exactly once for web preview performance requests.`); + } + return values[0]; +} + function resolveInspectedWebPreviewTarget(searchParams: URLSearchParams): Record { if (!activeWebPreviewTarget) { throw new ApiRequestError(404, 'Start valdi debugger with --web-preview-url before opening the DevTools panel.'); @@ -1267,6 +1309,106 @@ function resolveInspectedWebPreviewTarget(searchParams: URLSearchParams): Record return { target: webPreviewTargetPayload(activeWebPreviewTarget) }; } +function decorateWebPreviewTraceResult( + result: Record, + target: WebPreviewDebuggerTarget, +): Record { + return decorateTraceResult( + { ...result, webPreviewTrace: true }, + { ...webPreviewTargetPayload(target), state: 'attached' }, + ); +} + +function webPreviewTraceCaptureResult(capture: WebPreviewTraceCapture): Record { + return { + ...capture, + completedRecordingAvailable: false, + recording: false, + tracingSupported: true, + }; +} + +async function inspectWebPreviewPerformanceSnapshot( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise> { + if (request.method !== 'GET') { + throw new ApiRequestError(405, 'Web preview performance snapshots require GET.'); + } + const { identity, target } = resolveWebPreviewPerformanceIdentity(searchParams); + return { + ...(await webPreviewPerformanceController.snapshot(identity)), + target: { ...webPreviewTargetPayload(target), state: 'attached' }, + }; +} + +async function inspectWebPreviewPerformanceTraceStatus( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise> { + if (request.method !== 'GET') { + throw new ApiRequestError(405, 'Web preview performance trace status requires GET.'); + } + const { identity, target } = resolveWebPreviewPerformanceIdentity(searchParams); + return decorateWebPreviewTraceResult({ ...(await webPreviewPerformanceController.status(identity)) }, target); +} + +async function startWebPreviewPerformanceTrace( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise> { + if (request.method !== 'POST') { + throw new ApiRequestError(405, 'Web preview performance trace start requires POST.'); + } + await readJsonBody(request); + const { identity, target } = resolveWebPreviewPerformanceIdentity(searchParams); + return decorateWebPreviewTraceResult({ ...(await webPreviewPerformanceController.start(identity)) }, target); +} + +async function stopWebPreviewPerformanceTrace( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise> { + if (request.method !== 'POST') { + throw new ApiRequestError(405, 'Web preview performance trace stop requires POST.'); + } + await readJsonBody(request); + const { identity, target } = resolveWebPreviewPerformanceIdentity(searchParams); + const capture = await webPreviewPerformanceController.stop(identity); + return decorateWebPreviewTraceResult(webPreviewTraceCaptureResult(capture), target); +} + +async function captureWebPreviewPerformanceTrace( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise> { + if (request.method !== 'POST') { + throw new ApiRequestError(405, 'Web preview performance trace capture requires POST.'); + } + const body = await readJsonBody(request); + const durationMs = normalizeTraceCaptureDurationMs(body); + const { identity, target } = resolveWebPreviewPerformanceIdentity(searchParams); + const capture = await webPreviewPerformanceController.capture(identity, durationMs); + return decorateWebPreviewTraceResult(webPreviewTraceCaptureResult(capture), target); +} + +async function enableWebPreviewPerformanceTracing( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise> { + if (request.method !== 'POST') { + throw new ApiRequestError(405, 'Web preview renderer tracing enablement requires POST.'); + } + await readJsonBody(request); + const { identity, target } = resolveWebPreviewPerformanceIdentity(searchParams); + const inspectedUrl = await webPreviewPerformanceController.enableTracing(identity); + return { + inspectedUrl, + rendererTracingEnabled: true, + target: { ...webPreviewTargetPayload(target), state: 'attached' }, + }; +} + function readUnknownRecord(value: unknown): Record { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; } @@ -2760,6 +2902,8 @@ export function decorateTraceResult( typeof result['completionError'] === 'string' ? truncateStringForJson(result['completionError'], MAX_TRACE_HTTP_STRING_BYTES) : undefined; + const browserMetrics = readBrowserTraceMetrics(result['browserMetrics']); + const webPreviewTrace = result['webPreviewTrace'] === true; const buildResult = (includedTraceCount: number): Record => { const includedTraces = traces.slice(0, includedTraceCount); @@ -2784,6 +2928,13 @@ export function decorateTraceResult( traceEventLimitReached: droppedTraceEventCount > 0, summary: summarizeTraces(includedTraces), perfettoMetadata, + ...(webPreviewTrace + ? { + browserMetrics, + browserSummary: summarizeBrowserTraces(includedTraces), + webPreviewTrace: true, + } + : {}), }; }; @@ -2806,6 +2957,39 @@ export function decorateTraceResult( return boundedResult; } +function readBrowserTraceMetrics(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; + const record = value as Record; + const metrics: Record = {}; + for (const name of ['TaskDurationMs', 'ScriptDurationMs', 'LayoutDurationMs', 'LayoutCount', 'RecalcStyleCount']) { + const candidate = record[name]; + if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0) { + metrics[name] = candidate; + } + } + return metrics; +} + +function summarizeBrowserTraces(traces: readonly RecordedTrace[]): Record { + let browserEventCount = 0; + let frameCount = 0; + let longTaskCount = 0; + let longestTaskMs = 0; + let rendererEventCount = 0; + for (const trace of traces) { + if (trace.trace.startsWith('Valdi.')) rendererEventCount++; + if (!trace.trace.startsWith('Browser.')) continue; + browserEventCount++; + if (trace.trace.startsWith('Browser.Frames.')) frameCount++; + if (trace.trace === 'Browser.MainThread.Task') { + const durationMs = Math.max(0, trace.endMicros - trace.startMicros) / 1000; + if (durationMs >= 50) longTaskCount++; + longestTaskMs = Math.max(longestTaskMs, durationMs); + } + } + return { browserEventCount, frameCount, longTaskCount, longestTaskMs, rendererEventCount }; +} + function saturatingAdd(left: number, right: number): number { return left >= Number.MAX_SAFE_INTEGER - right ? Number.MAX_SAFE_INTEGER : left + right; } @@ -2844,6 +3028,7 @@ export function readRecordedTraces(value: unknown): RecordedTrace[] { startMicros, endMicros, threadId, + ...(candidate['type'] === 1 ? { type: 1 } : {}), }; traces.push(recordedTrace); } @@ -2857,7 +3042,7 @@ export function summarizeTraces(traces: readonly RecordedTrace[]): RendererTrace const viewModelTriggers = new Map(); for (const trace of traces) { - if (isRendererViewModelChangeTrace(trace.trace)) { + if (trace.type === 1 || isRendererViewModelChangeTrace(trace.trace)) { instantTraceCount += 1; } else { durationTraceCount += 1; @@ -2936,7 +3121,7 @@ export function buildPerfettoTracePayload( } for (const trace of traces) { - const isInstant = isRendererViewModelChangeTrace(trace.trace); + const isInstant = trace.type === 1 || isRendererViewModelChangeTrace(trace.trace); const event: PerfettoTraceEvent = { name: trace.trace, cat: PERFETTO_TRACE_CATEGORY, @@ -3252,6 +3437,36 @@ async function handleApi(request: IncomingMessage, response: ServerResponse, url return; } + if (url.pathname === '/api/devtools/performance/snapshot') { + sendJson(response, 200, await inspectWebPreviewPerformanceSnapshot(request, url.searchParams)); + return; + } + + if (url.pathname === '/api/devtools/performance/trace/status') { + sendTraceJson(response, 200, await inspectWebPreviewPerformanceTraceStatus(request, url.searchParams)); + return; + } + + if (url.pathname === '/api/devtools/performance/trace/start') { + sendTraceJson(response, 200, await startWebPreviewPerformanceTrace(request, url.searchParams)); + return; + } + + if (url.pathname === '/api/devtools/performance/trace/stop') { + sendTraceJson(response, 200, await stopWebPreviewPerformanceTrace(request, url.searchParams)); + return; + } + + if (url.pathname === '/api/devtools/performance/trace/capture') { + sendTraceJson(response, 200, await captureWebPreviewPerformanceTrace(request, url.searchParams)); + return; + } + + if (url.pathname === '/api/devtools/performance/trace/enable') { + sendJson(response, 200, await enableWebPreviewPerformanceTracing(request, url.searchParams)); + return; + } + if (url.pathname === '/api/devtools/console/stream') { if (request.method !== 'GET') { sendJson(response, 405, { error: 'Valdi DevTools console streaming requires GET.' }); @@ -3364,7 +3579,10 @@ async function handleApi(request: IncomingMessage, response: ServerResponse, url } catch (error) { const status = error instanceof ApiRequestError ? error.statusCode : 500; const payload = clientErrorPayload(error); - if (url.pathname.startsWith('/api/performance/trace/')) { + if ( + url.pathname.startsWith('/api/performance/trace/') || + url.pathname.startsWith('/api/devtools/performance/trace/') + ) { sendTraceJson(response, status, payload); } else { sendJson(response, status, payload); @@ -3509,6 +3727,11 @@ async function closeDebuggerServer(server: Server): Promise { // Wait for in-flight profile setup/capture requests before inspecting the // active session so shutdown cannot orphan a session created late. await closeHttpServer(server); + try { + await webPreviewPerformanceController.close(); + } catch (error) { + console.warn(`Could not stop the active web preview trace during shutdown: ${errorPayload(error).error}`); + } if (activeProfileSession) { try { await stopActiveProfileSession(); diff --git a/npm_modules/cli/src/debugger/webPreviewPerformance.spec.ts b/npm_modules/cli/src/debugger/webPreviewPerformance.spec.ts new file mode 100644 index 00000000..360b1399 --- /dev/null +++ b/npm_modules/cli/src/debugger/webPreviewPerformance.spec.ts @@ -0,0 +1,392 @@ +import 'jasmine'; +import { + MAX_WEB_PREVIEW_TRACE_EVENTS, + WEB_PREVIEW_TRACE_RESULT_TTL_MS, + WEB_PREVIEW_TRACE_WATCHDOG_MS, + WebPreviewPerformanceController, + type WebPreviewPerformanceControllerDependencies, + type WebPreviewPerformanceIdentity, + type WebPreviewTraceCapture, + WebPreviewTraceLifecycleError, + WebPreviewTraceNormalizer, + type WebPreviewTraceRecorderLike, + normalizeWebPreviewPerformanceMetrics, + saturatingWebPreviewTraceEventCount, +} from './webPreviewPerformance'; + +const FIRST_IDENTITY: WebPreviewPerformanceIdentity = { + applicationUrl: 'http://127.0.0.1:54321/index.html', + debuggingPort: 9222, + inspectedUrl: 'http://127.0.0.1:54321/index.html?valdiDebugger=1', + sessionId: 'web-preview', + targetNonce: '0123456789abcdef', +}; + +const SECOND_IDENTITY: WebPreviewPerformanceIdentity = { + ...FIRST_IDENTITY, + inspectedUrl: 'http://127.0.0.1:54321/index.html?screen=second&valdiDebugger=1', + targetNonce: 'fedcba9876543210', +}; + +const DUPLICATE_URL_IDENTITY: WebPreviewPerformanceIdentity = { + ...FIRST_IDENTITY, + targetNonce: 'duplicate12345678', +}; + +function capture(startedAtEpochMs: number): WebPreviewTraceCapture { + return { + browserMetrics: { TaskDurationMs: 12 }, + droppedTraceEventCount: 0, + elapsedMs: 25, + rendererTracingEnabled: true, + startedAtEpochMs, + timedOut: false, + traces: [{ endMicros: 20, startMicros: 10, threadId: 1, trace: 'Valdi.Renderer.onRender.Example' }], + }; +} + +class FakeRecorder implements WebPreviewTraceRecorderLike { + closed = false; + stopError: Error | undefined; + readonly stopArguments: boolean[] = []; + + constructor( + private readonly startedAtEpochMs: number, + private readonly result: WebPreviewTraceCapture, + ) {} + + close(): void { + this.closed = true; + } + + status(nowMs: number) { + return { + completedRecordingAvailable: false, + elapsedMs: nowMs - this.startedAtEpochMs, + recording: true, + rendererTracingEnabled: true, + startedAtEpochMs: this.startedAtEpochMs, + tracingSupported: true as const, + }; + } + + stop(timedOut: boolean): Promise { + this.stopArguments.push(timedOut); + this.closed = true; + if (this.stopError) return Promise.reject(this.stopError); + return Promise.resolve({ ...this.result, timedOut }); + } + + stopForRecovery(): Promise { + return this.stop(false).then(() => {}); + } +} + +interface ControllerHarness { + controller: WebPreviewPerformanceController; + recorders: FakeRecorder[]; + waits: number[]; + advance(durationMs: number): void; + runTimer(durationMs: number): Promise; + setOwnerPresence(value: boolean | Error): void; +} + +function createControllerHarness(): ControllerHarness { + let nowMs = 1000; + let timerId = 0; + const timers = new Map void; durationMs: number }>(); + const recorders: FakeRecorder[] = []; + const waits: number[] = []; + let ownerPresence: boolean | Error = true; + const dependencies: WebPreviewPerformanceControllerDependencies = { + clearTimer: timer => timers.delete(timer), + enableTracing: identity => Promise.resolve(`${identity.inspectedUrl}&valdiTrace=chrome`), + now: () => nowMs, + readSnapshot: () => + Promise.resolve({ + mainThread: {}, + memory: null, + navigation: {}, + paints: [], + rendererTracingEnabled: false, + resourceCount: 0, + transferSize: 0, + uptimeMs: 0, + }), + setTimer: (callback, durationMs) => { + const timer = { timerId: ++timerId } as unknown as NodeJS.Timeout; + timers.set(timer, { callback, durationMs }); + return timer; + }, + startRecorder: () => { + const recorder = new FakeRecorder(nowMs, capture(nowMs)); + recorders.push(recorder); + return Promise.resolve(recorder); + }, + targetPresent: () => + ownerPresence instanceof Error ? Promise.reject(ownerPresence) : Promise.resolve(ownerPresence), + wait: durationMs => { + waits.push(durationMs); + nowMs += durationMs; + return Promise.resolve(); + }, + }; + return { + advance: durationMs => { + nowMs += durationMs; + }, + controller: new WebPreviewPerformanceController(dependencies), + recorders, + runTimer: async durationMs => { + const entry = Array.from(timers.values()).find(candidate => candidate.durationMs === durationMs); + if (!entry) throw new Error(`No ${durationMs.toString()}ms timer is scheduled.`); + entry.callback(); + await new Promise(resolve => setImmediate(resolve)); + }, + setOwnerPresence: value => { + ownerPresence = value; + }, + waits, + }; +} + +describe('web preview performance', () => { + it('incrementally normalizes allowlisted browser and Valdi events without retaining unrelated payloads', () => { + const normalizer = new WebPreviewTraceNormalizer(); + normalizer.accept({ + args: { data: { end: 1300, name: 'Valdi.Renderer.onRender.Example', start: 1000 } }, + name: 'TimeStamp', + ph: 'I', + tid: 7, + ts: 1100, + }); + normalizer.accept({ args: { private: 'discarded' }, dur: 500, name: 'Layout', ph: 'X', tid: 7, ts: 1400 }); + normalizer.accept({ id: 'render', name: 'Valdi.StateChange', ph: 'B', tid: 7, ts: 2000 }); + normalizer.accept({ id: 'render', name: 'Valdi.StateChange', ph: 'E', tid: 7, ts: 2300 }); + normalizer.accept({ args: { large: 'x'.repeat(100_000) }, name: 'Unrelated', ph: 'X', tid: 7, ts: 2500 }); + + const result = normalizer.result(); + expect(result).toEqual({ + droppedTraceEventCount: 0, + traces: [ + { endMicros: 1300, startMicros: 1000, threadId: 7, trace: 'Valdi.Renderer.onRender.Example' }, + { endMicros: 1900, startMicros: 1400, threadId: 7, trace: 'Browser.Layout.Layout' }, + { endMicros: 2300, startMicros: 2000, threadId: 7, trace: 'Valdi.StateChange' }, + ], + }); + expect(JSON.stringify(result)).not.toContain('private'); + expect(JSON.stringify(result)).not.toContain('Unrelated'); + }); + + it('uses collision-free begin/end identities and counts unfinished pairs as dropped', () => { + const normalizer = new WebPreviewTraceNormalizer(); + normalizer.accept({ id: 'c', name: 'Valdi.a:b', ph: 'B', tid: 1, ts: 10 }); + normalizer.accept({ id: 'b:c', name: 'Valdi.a', ph: 'B', tid: 1, ts: 20 }); + normalizer.accept({ id: 'c', name: 'Valdi.a:b', ph: 'E', tid: 1, ts: 30 }); + normalizer.accept({ id: 'unfinished', name: 'Valdi.pending', ph: 'B', tid: 1, ts: 40 }); + + const result = normalizer.result(); + expect(result.traces).toEqual([{ endMicros: 30, startMicros: 10, threadId: 1, trace: 'Valdi.a:b' }]); + expect(result.droppedTraceEventCount).toBe(2); + expect(normalizer.result()).toEqual(result); + }); + + it('retains only the fixed Chromium metric whitelist in a prototype-safe record', () => { + const metrics = normalizeWebPreviewPerformanceMetrics({ + metrics: [ + { name: 'TaskDuration', value: 1.5 }, + { name: 'JSHeapUsedSize', value: 2048 }, + { name: '__proto__', value: 1 }, + { name: 'PrivateMetric', value: 42 }, + ], + }); + + expect(Object.getPrototypeOf(metrics)).toBeNull(); + expect({ ...metrics }).toEqual({ JSHeapUsedSize: 2048, TaskDuration: 1.5 }); + expect(metrics['PrivateMetric']).toBeUndefined(); + }); + + it('enforces the event and UTF-8 trace-name bounds during normalization', () => { + const normalizer = new WebPreviewTraceNormalizer(); + const allowedName = `Valdi.${'é'.repeat(1021)}`; + const oversizedName = `Valdi.${'é'.repeat(1022)}`; + normalizer.accept({ name: allowedName, ph: 'I', tid: 1, ts: 1 }); + normalizer.accept({ name: oversizedName, ph: 'I', tid: 1, ts: 2 }); + for (let index = 1; index <= MAX_WEB_PREVIEW_TRACE_EVENTS; index++) { + normalizer.accept({ name: 'Layout', ph: 'X', tid: 1, ts: index + 2 }); + } + + const result = normalizer.result(); + expect(result.traces.length).toBe(MAX_WEB_PREVIEW_TRACE_EVENTS); + expect(result.traces[0]?.trace).toBe(allowedName); + expect(result.traces.some(trace => trace.trace === oversizedName)).toBeFalse(); + expect(result.droppedTraceEventCount).toBe(1); + expect(saturatingWebPreviewTraceEventCount(Number.MAX_SAFE_INTEGER - 1, 10)).toBe(Number.MAX_SAFE_INTEGER); + }); + + it('keeps one global recording when another same-URL tab still has the owner nonce', async () => { + const harness = createControllerHarness(); + await harness.controller.start(FIRST_IDENTITY); + + await expectAsync(harness.controller.status(SECOND_IDENTITY)).toBeRejectedWithError( + 'Another inspected web preview owns the current Chromium performance trace.', + ); + await expectAsync(harness.controller.stop(SECOND_IDENTITY)).toBeRejectedWithError( + 'Another inspected web preview owns the current Chromium performance trace.', + ); + await expectAsync(harness.controller.status(DUPLICATE_URL_IDENTITY)).toBeRejectedWithError( + 'Another inspected web preview owns the current Chromium performance trace.', + ); + await expectAsync(harness.controller.start(SECOND_IDENTITY)).toBeRejectedWithError( + 'Another inspected web preview owns the current Chromium performance trace.', + ); + + const result = await harness.controller.stop(FIRST_IDENTITY); + expect(result.traces.length).toBe(1); + expect(harness.recorders[0]?.stopArguments).toEqual([false]); + }); + + it('ends and replaces an old recording only after its nonce-bound target is verified missing', async () => { + const harness = createControllerHarness(); + await harness.controller.start(FIRST_IDENTITY); + harness.setOwnerPresence(false); + + const replacement = await harness.controller.start(DUPLICATE_URL_IDENTITY); + + expect(replacement.recording).toBeTrue(); + expect(harness.recorders.length).toBe(2); + expect(harness.recorders[0]?.stopArguments).toEqual([false]); + await harness.controller.stop(DUPLICATE_URL_IDENTITY); + }); + + it('retains the old owner when its trace cannot be ended after the target disappears', async () => { + const harness = createControllerHarness(); + await harness.controller.start(FIRST_IDENTITY); + const recorder = harness.recorders[0]; + if (!recorder) throw new Error('Expected an active fake recorder.'); + recorder.stopError = new Error('Synthetic Tracing.end failure.'); + harness.setOwnerPresence(false); + + await expectAsync(harness.controller.start(DUPLICATE_URL_IDENTITY)).toBeRejectedWithError( + 'Could not end the previous web preview performance trace after its inspected target disappeared: Synthetic Tracing.end failure.', + ); + expect(harness.recorders.length).toBe(1); + expect(recorder.stopArguments).toEqual([false]); + await expectAsync(harness.controller.status(DUPLICATE_URL_IDENTITY)).toBeRejectedWithError( + 'Could not end the previous web preview performance trace after its inspected target disappeared: Synthetic Tracing.end failure.', + ); + }); + + it('fails closed when the old owner target cannot be verified present or missing', async () => { + const harness = createControllerHarness(); + await harness.controller.start(FIRST_IDENTITY); + harness.setOwnerPresence(new Error('Synthetic discovery uncertainty.')); + + await expectAsync(harness.controller.start(DUPLICATE_URL_IDENTITY)).toBeRejectedWithError( + 'Synthetic discovery uncertainty.', + ); + expect(harness.recorders[0]?.stopArguments).toEqual([]); + harness.setOwnerPresence(true); + await harness.controller.stop(FIRST_IDENTITY); + }); + + it('finalizes on the watchdog and expires an undelivered result after sixty seconds', async () => { + const harness = createControllerHarness(); + await harness.controller.start(FIRST_IDENTITY); + harness.advance(WEB_PREVIEW_TRACE_WATCHDOG_MS); + await harness.runTimer(WEB_PREVIEW_TRACE_WATCHDOG_MS); + + expect(await harness.controller.status(FIRST_IDENTITY)).toEqual( + jasmine.objectContaining({ completedRecordingAvailable: true, recording: false }), + ); + expect(harness.recorders[0]?.stopArguments).toEqual([true]); + + const firstDelivery = await harness.controller.stop(FIRST_IDENTITY); + const replay = await harness.controller.stop(FIRST_IDENTITY); + expect(firstDelivery.timedOut).toBeTrue(); + expect(replay).toEqual(firstDelivery); + + await harness.controller.start(FIRST_IDENTITY); + harness.advance(WEB_PREVIEW_TRACE_WATCHDOG_MS); + await harness.runTimer(WEB_PREVIEW_TRACE_WATCHDOG_MS); + + harness.advance(WEB_PREVIEW_TRACE_RESULT_TTL_MS); + await harness.runTimer(WEB_PREVIEW_TRACE_RESULT_TTL_MS); + expect(await harness.controller.status(FIRST_IDENTITY)).toEqual( + jasmine.objectContaining({ completedRecordingAvailable: false, recording: false }), + ); + await expectAsync(harness.controller.stop(FIRST_IDENTITY)).toBeRejectedWithError( + 'No Chromium performance trace is available for the inspected web preview.', + ); + }); + + it('uses the requested bounded duration for one-shot capture', async () => { + const harness = createControllerHarness(); + + const result = await harness.controller.capture(FIRST_IDENTITY, 1250); + + expect(harness.waits).toEqual([1250]); + expect(result.timedOut).toBeFalse(); + expect(harness.recorders[0]?.stopArguments).toEqual([false]); + await expectAsync(harness.runTimer(WEB_PREVIEW_TRACE_WATCHDOG_MS)).toBeRejectedWithError( + `No ${WEB_PREVIEW_TRACE_WATCHDOG_MS.toString()}ms timer is scheduled.`, + ); + }); + + it('retains fail-closed ownership when explicit Stop cannot confirm trace termination', async () => { + const harness = createControllerHarness(); + await harness.controller.start(FIRST_IDENTITY); + const recorder = harness.recorders[0]; + if (!recorder) throw new Error('Expected an active fake recorder.'); + recorder.stopError = new Error('Synthetic completion failure.'); + + await expectAsync(harness.controller.stop(FIRST_IDENTITY)).toBeRejectedWithError('Synthetic completion failure.'); + await expectAsync(harness.controller.status(FIRST_IDENTITY)).toBeRejectedWithError('Synthetic completion failure.'); + await expectAsync(harness.controller.start(FIRST_IDENTITY)).toBeRejectedWithError('Synthetic completion failure.'); + }); + + it('releases explicit Stop ownership when the trace ended before result acceptance failed', async () => { + const harness = createControllerHarness(); + await harness.controller.start(FIRST_IDENTITY); + const recorder = harness.recorders[0]; + if (!recorder) throw new Error('Expected an active fake recorder.'); + recorder.stopError = new WebPreviewTraceLifecycleError('Synthetic post-trace validation failure.', true, null); + + await expectAsync(harness.controller.stop(FIRST_IDENTITY)).toBeRejectedWithError( + 'Synthetic post-trace validation failure.', + ); + await expectAsync(harness.controller.start(FIRST_IDENTITY)).toBeResolved(); + }); + + it('acknowledges and clears an undelivered watchdog completion error through stop', async () => { + const harness = createControllerHarness(); + await harness.controller.start(FIRST_IDENTITY); + const recorder = harness.recorders[0]; + if (!recorder) throw new Error('Expected an active fake recorder.'); + recorder.stopError = new WebPreviewTraceLifecycleError('Synthetic watchdog failure.', true, null); + harness.advance(WEB_PREVIEW_TRACE_WATCHDOG_MS); + await harness.runTimer(WEB_PREVIEW_TRACE_WATCHDOG_MS); + + expect(await harness.controller.status(FIRST_IDENTITY)).toEqual( + jasmine.objectContaining({ completionError: 'Synthetic watchdog failure.', recording: false }), + ); + await expectAsync(harness.controller.stop(FIRST_IDENTITY)).toBeRejectedWithError('Synthetic watchdog failure.'); + await expectAsync(harness.controller.start(FIRST_IDENTITY)).toBeResolved(); + }); + + it('closes an active recorder and cancels its watchdog during shutdown', async () => { + const harness = createControllerHarness(); + await harness.controller.start(FIRST_IDENTITY); + + await harness.controller.close(); + + expect(harness.recorders[0]?.closed).toBeTrue(); + expect(harness.recorders[0]?.stopArguments).toEqual([false]); + expect(await harness.controller.status(FIRST_IDENTITY)).toEqual( + jasmine.objectContaining({ completedRecordingAvailable: false, recording: false }), + ); + await expectAsync(harness.runTimer(WEB_PREVIEW_TRACE_WATCHDOG_MS)).toBeRejectedWithError( + `No ${WEB_PREVIEW_TRACE_WATCHDOG_MS.toString()}ms timer is scheduled.`, + ); + }); +}); diff --git a/npm_modules/cli/src/debugger/webPreviewPerformance.ts b/npm_modules/cli/src/debugger/webPreviewPerformance.ts new file mode 100644 index 00000000..7e2b5543 --- /dev/null +++ b/npm_modules/cli/src/debugger/webPreviewPerformance.ts @@ -0,0 +1,1021 @@ +import { ChromiumDevToolsProtocolError } from '../utils/chromiumDevToolsClient'; +import { + OwlChromiumConnection, + connectToOwlApplication, + listOwlChromiumTargets, + matchesOwlApplicationUrl, +} from '../utils/owlCdpClient'; + +const CHROMIUM_COMMAND_TIMEOUT_MS = 10_000; +const TRACE_COMPLETION_TIMEOUT_MS = 10_000; +const RECORDED_TRACE_TYPE_INSTANT = 1; +const MIN_BROWSER_TASK_DURATION_MICROS = 100; +const MAX_ACTIVE_TRACE_PAIRS = 10_000; +const MAX_TRACE_PAIR_ID_BYTES = 256; +const MAX_PAINT_ENTRIES = 16; +const MAX_PAINT_NAME_BYTES = 256; +const CHROMIUM_METRIC_NAMES = new Set([ + 'TaskDuration', + 'ScriptDuration', + 'LayoutDuration', + 'LayoutCount', + 'RecalcStyleCount', + 'JSHeapUsedSize', + 'JSHeapTotalSize', +]); +const TRACE_CATEGORIES = [ + 'devtools.timeline', + 'blink.user_timing', + 'blink.console', + 'disabled-by-default-devtools.timeline', +].join(','); + +export const MAX_WEB_PREVIEW_TRACE_EVENTS = 10_000; +export const MAX_WEB_PREVIEW_TRACE_NAME_BYTES = 2048; +export const WEB_PREVIEW_TRACE_WATCHDOG_MS = 15_000; +export const WEB_PREVIEW_TRACE_RESULT_TTL_MS = 60_000; + +export interface WebPreviewPerformanceIdentity { + applicationUrl: string; + debuggingPort: number; + inspectedUrl: string; + sessionId: string; + targetNonce: string; +} + +export interface WebPreviewRecordedTrace { + endMicros: number; + startMicros: number; + threadId: number; + trace: string; + type?: number; +} + +export interface WebPreviewTraceCapture { + browserMetrics: Record; + droppedTraceEventCount: number; + elapsedMs: number; + rendererTracingEnabled: boolean; + startedAtEpochMs: number; + timedOut: boolean; + traces: WebPreviewRecordedTrace[]; +} + +export interface WebPreviewPerformanceSnapshot { + mainThread: { + layoutDurationMs?: number; + scriptDurationMs?: number; + taskDurationMs?: number; + }; + memory: { + totalBytes?: number; + usedBytes?: number; + } | null; + navigation: { + domContentLoadedMs?: number; + loadMs?: number; + }; + paints: Array<{ name: string; startTime: number }>; + rendererTracingEnabled: boolean; + resourceCount: number; + transferSize: number; + uptimeMs: number; +} + +export interface WebPreviewTraceStatus { + completedRecordingAvailable: boolean; + completionError?: string; + elapsedMs?: number; + recording: boolean; + rendererTracingEnabled: boolean; + startedAtEpochMs?: number; + tracingSupported: true; +} + +interface ActiveTracePair { + name: string; + startMicros: number; + threadId: number; +} + +interface ChromiumMetricsResult { + metrics?: Array<{ name: string; value: number }>; +} + +interface TraceCompletion { + capture?: WebPreviewTraceCapture; + delivered: boolean; + error?: Error; + expiresAtMs: number; + identity: WebPreviewPerformanceIdentity; +} + +interface ActiveWebPreviewTrace { + cleanupError?: Error; + identity: WebPreviewPerformanceIdentity; + recorder: WebPreviewTraceRecorderLike; + watchdog: NodeJS.Timeout | undefined; +} + +interface TransitionGate { + promise: Promise; + resolve(): void; +} + +export interface WebPreviewTraceRecorderLike { + close(): void; + status(nowMs: number): WebPreviewTraceStatus; + stop(timedOut: boolean): Promise; + stopForRecovery(): Promise; +} + +export class WebPreviewTraceLifecycleError extends Error { + constructor( + message: string, + readonly traceEnded: boolean, + readonly recorder: WebPreviewTraceRecorderLike | null, + ) { + super(message); + this.name = 'WebPreviewTraceLifecycleError'; + } +} + +export interface WebPreviewPerformanceControllerDependencies { + clearTimer(timer: NodeJS.Timeout): void; + enableTracing(identity: WebPreviewPerformanceIdentity): Promise; + now(): number; + readSnapshot(identity: WebPreviewPerformanceIdentity): Promise; + setTimer(callback: () => void, durationMs: number): NodeJS.Timeout; + startRecorder(identity: WebPreviewPerformanceIdentity): Promise; + targetPresent(identity: WebPreviewPerformanceIdentity): Promise; + wait(durationMs: number): Promise; +} + +const BROWSER_TRACE_NAMES: ReadonlyMap = new Map([ + ['RunTask', 'Browser.MainThread.Task'], + ['FunctionCall', 'Browser.JavaScript.FunctionCall'], + ['EvaluateScript', 'Browser.JavaScript.EvaluateScript'], + ['EventDispatch', 'Browser.JavaScript.EventDispatch'], + ['TimerFire', 'Browser.JavaScript.TimerFire'], + ['FireAnimationFrame', 'Browser.JavaScript.AnimationFrame'], + ['UpdateLayoutTree', 'Browser.Layout.UpdateLayoutTree'], + ['Layout', 'Browser.Layout.Layout'], + ['RecalculateStyles', 'Browser.Layout.RecalculateStyles'], + ['ScheduleStyleRecalculation', 'Browser.Layout.ScheduleStyleRecalculation'], + ['Paint', 'Browser.Paint.Paint'], + ['PrePaint', 'Browser.Paint.PrePaint'], + ['Layerize', 'Browser.Paint.Layerize'], + ['RasterTask', 'Browser.Paint.RasterTask'], + ['CompositeLayers', 'Browser.Paint.CompositeLayers'], + ['BeginFrame', 'Browser.Frames.BeginFrame'], + ['DrawFrame', 'Browser.Frames.DrawFrame'], + ['AnimationFrame', 'Browser.Frames.AnimationFrame'], + ['AnimationFrame::Render', 'Browser.Frames.Render'], + ['Commit', 'Browser.Frames.Commit'], + ['MinorGC', 'Browser.GC.Minor'], + ['MajorGC', 'Browser.GC.Major'], +]); + +const PERFORMANCE_SNAPSHOT_EXPRESSION = `(() => { + const resources = globalThis.performance.getEntriesByType('resource'); + const navigation = globalThis.performance.getEntriesByType('navigation')[0]; + const paints = globalThis.performance.getEntriesByType('paint').slice(0, ${MAX_PAINT_ENTRIES}); + const parameters = new URLSearchParams(globalThis.location.search); + return { + navigation: navigation ? { + domContentLoadedMs: navigation.domContentLoadedEventEnd, + loadMs: navigation.loadEventEnd, + } : {}, + paints: paints.map(entry => ({ name: String(entry.name), startTime: entry.startTime })), + rendererTracingEnabled: + parameters.getAll('valdiDevTools').length === 1 && parameters.get('valdiDevTools') === '1' && + parameters.getAll('valdiTrace').length === 1 && parameters.get('valdiTrace') === 'chrome', + resourceCount: resources.length, + transferSize: resources.reduce((total, entry) => total + (Number(entry.transferSize) || 0), 0), + uptimeMs: globalThis.performance.now(), + }; +})()`; + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function boundedSafeInteger(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined; + const rounded = Math.round(value); + return Number.isSafeInteger(rounded) ? rounded : undefined; +} + +function finiteNonNegativeNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function traceName(value: unknown): string | undefined { + return typeof value === 'string' && + value.length > 0 && + Buffer.byteLength(value, 'utf8') <= MAX_WEB_PREVIEW_TRACE_NAME_BYTES + ? value + : undefined; +} + +function normalizedTraceName(value: unknown): string | undefined { + const name = traceName(value); + if (name === undefined) return undefined; + if (name.startsWith('Valdi.')) return name; + return BROWSER_TRACE_NAMES.get(name); +} + +function tracePairId(value: unknown): string | undefined { + if (value === undefined) return ''; + if (typeof value !== 'string' && typeof value !== 'number') return undefined; + const serialized = String(value); + return Buffer.byteLength(serialized, 'utf8') <= MAX_TRACE_PAIR_ID_BYTES ? serialized : undefined; +} + +export function saturatingWebPreviewTraceEventCount(left: number, right: number): number { + return left >= Number.MAX_SAFE_INTEGER - right ? Number.MAX_SAFE_INTEGER : left + right; +} + +export class WebPreviewTraceNormalizer { + private readonly activePairs = new Map(); + private activePairCount = 0; + private droppedTraceEventCount = 0; + private finalized = false; + private readonly traces: WebPreviewRecordedTrace[] = []; + + accept(value: unknown): void { + if (this.finalized) return; + const event = asRecord(value); + if (event === undefined) return; + + const timestampTrace = this.normalizeTimestamp(event); + if (timestampTrace !== undefined) { + this.append(timestampTrace); + return; + } + + const instantOrDuration = this.normalizeInstantOrDuration(event); + if (instantOrDuration !== undefined) { + this.append(instantOrDuration); + return; + } + + const name = normalizedTraceName(event['name']); + const timestamp = boundedSafeInteger(event['ts']); + const pairId = tracePairId(event['id']); + if (name === undefined || timestamp === undefined || pairId === undefined) return; + + const threadId = boundedSafeInteger(event['tid']) ?? 0; + const key = JSON.stringify([threadId, name, pairId]); + if (event['ph'] === 'B' || event['ph'] === 'b') { + if (this.activePairCount >= MAX_ACTIVE_TRACE_PAIRS) { + this.incrementDropped(); + return; + } + const active = this.activePairs.get(key) ?? []; + active.push({ name, startMicros: timestamp, threadId }); + this.activePairs.set(key, active); + this.activePairCount++; + return; + } + if (event['ph'] !== 'E' && event['ph'] !== 'e') return; + + const active = this.activePairs.get(key); + const start = active?.pop(); + if (start === undefined) return; + this.activePairCount--; + if (active?.length === 0) this.activePairs.delete(key); + this.append({ + endMicros: Math.max(start.startMicros, timestamp), + startMicros: start.startMicros, + threadId: start.threadId, + trace: start.name, + }); + } + + dataLossOccurred(): void { + if (this.finalized) return; + this.incrementDropped(); + } + + result(): { droppedTraceEventCount: number; traces: WebPreviewRecordedTrace[] } { + if (!this.finalized) { + this.droppedTraceEventCount = saturatingWebPreviewTraceEventCount( + this.droppedTraceEventCount, + this.activePairCount, + ); + this.activePairs.clear(); + this.activePairCount = 0; + this.finalized = true; + } + return { + droppedTraceEventCount: this.droppedTraceEventCount, + traces: [...this.traces].sort( + (left, right) => left.startMicros - right.startMicros || right.endMicros - left.endMicros, + ), + }; + } + + private append(trace: WebPreviewRecordedTrace): void { + if (this.traces.length >= MAX_WEB_PREVIEW_TRACE_EVENTS) { + this.incrementDropped(); + return; + } + this.traces.push(trace); + } + + private incrementDropped(): void { + this.droppedTraceEventCount = saturatingWebPreviewTraceEventCount(this.droppedTraceEventCount, 1); + } + + private normalizeTimestamp(event: Record): WebPreviewRecordedTrace | undefined { + if (event['name'] !== 'TimeStamp') return undefined; + const data = asRecord(asRecord(event['args'])?.['data']); + const name = traceName(data?.['name']) ?? traceName(data?.['message']); + if (name === undefined || !name.startsWith('Valdi.')) return undefined; + const timestamp = boundedSafeInteger(event['ts']); + const startMicros = boundedSafeInteger(data?.['start']) ?? timestamp; + const endMicros = boundedSafeInteger(data?.['end']) ?? startMicros; + if (startMicros === undefined || endMicros === undefined) return undefined; + return { + endMicros: Math.max(startMicros, endMicros), + startMicros, + threadId: boundedSafeInteger(event['tid']) ?? 0, + trace: name, + ...(startMicros === endMicros ? { type: RECORDED_TRACE_TYPE_INSTANT } : {}), + }; + } + + private normalizeInstantOrDuration(event: Record): WebPreviewRecordedTrace | undefined { + const name = normalizedTraceName(event['name']); + const phase = String(event['ph']); + const startMicros = boundedSafeInteger(event['ts']); + if (name === undefined || startMicros === undefined || !['X', 'I', 'i', 'R'].includes(phase)) { + return undefined; + } + const durationMicros = boundedSafeInteger(event['dur']) ?? 0; + if (event['name'] === 'RunTask' && durationMicros < MIN_BROWSER_TASK_DURATION_MICROS) return undefined; + if (durationMicros > Number.MAX_SAFE_INTEGER - startMicros) return undefined; + return { + endMicros: startMicros + durationMicros, + startMicros, + threadId: boundedSafeInteger(event['tid']) ?? 0, + trace: name, + ...(phase === 'X' ? {} : { type: RECORDED_TRACE_TYPE_INSTANT }), + }; + } +} + +export function normalizeWebPreviewPerformanceMetrics(result: unknown): Record { + const metrics = (asRecord(result) as ChromiumMetricsResult | undefined)?.metrics; + const normalized = Object.create(null) as Record; + if (!Array.isArray(metrics)) return normalized; + for (const metric of metrics) { + if ( + typeof metric?.name === 'string' && + CHROMIUM_METRIC_NAMES.has(metric.name) && + typeof metric.value === 'number' && + Number.isFinite(metric.value) + ) { + normalized[metric.name] = metric.value; + } + } + return normalized; +} + +function metricDifferences(start: Record, end: Record): Record { + const result = Object.create(null) as Record; + for (const name of ['TaskDuration', 'ScriptDuration', 'LayoutDuration']) { + if (end[name] !== undefined) result[`${name}Ms`] = Math.max(0, end[name] - (start[name] ?? 0)) * 1000; + } + for (const name of ['LayoutCount', 'RecalcStyleCount']) { + if (end[name] !== undefined) result[name] = Math.max(0, end[name] - (start[name] ?? 0)); + } + return result; +} + +function rendererTracingEnabled(inspectedUrl: string): boolean { + try { + const parameters = new URL(inspectedUrl).searchParams; + return ( + parameters.getAll('valdiDevTools').length === 1 && + parameters.get('valdiDevTools') === '1' && + parameters.getAll('valdiTrace').length === 1 && + parameters.get('valdiTrace') === 'chrome' + ); + } catch { + return false; + } +} + +function identitiesMatch(left: WebPreviewPerformanceIdentity, right: WebPreviewPerformanceIdentity): boolean { + return ( + left.sessionId === right.sessionId && + left.inspectedUrl === right.inspectedUrl && + left.targetNonce === right.targetNonce + ); +} + +export async function isWebPreviewPerformanceTargetPresent(identity: WebPreviewPerformanceIdentity): Promise { + const targets = await listOwlChromiumTargets(identity.debuggingPort); + const candidates = targets.filter( + target => target.type === 'page' && matchesOwlApplicationUrl(target.url, identity.applicationUrl), + ); + let probeError: Error | undefined; + for (const candidate of candidates) { + let connection: OwlChromiumConnection | undefined; + try { + connection = await OwlChromiumConnection.connect(candidate.webSocketDebuggerUrl); + if (!(await connection.matchesTarget(identity.applicationUrl, identity.targetNonce))) continue; + const currentUrl = await connection.evaluate('String(globalThis.location.href)'); + if (typeof currentUrl !== 'string') { + throw new TypeError('The previous inspected web preview returned an invalid current URL.'); + } + if (new URL(currentUrl).toString() === new URL(identity.inspectedUrl).toString()) return true; + } catch (error) { + probeError = error instanceof Error ? error : new Error(String(error)); + } finally { + connection?.close(); + } + } + if (probeError) { + throw new Error( + `Could not verify whether the previous inspected web preview is still present: ${probeError.message}`, + ); + } + return false; +} + +function createTransitionGate(): TransitionGate { + let resolveGate: (() => void) | undefined; + const promise = new Promise(resolve => { + resolveGate = resolve; + }); + if (!resolveGate) throw new Error('Could not initialize the web preview performance transition gate.'); + return { promise, resolve: resolveGate }; +} + +async function assertCurrentTarget( + connection: OwlChromiumConnection, + identity: WebPreviewPerformanceIdentity, +): Promise { + if (!(await connection.matchesTarget(identity.applicationUrl, identity.targetNonce))) { + throw new Error('The inspected web preview changed while the performance request was running.'); + } +} + +async function assertInspectedUrl( + connection: OwlChromiumConnection, + identity: WebPreviewPerformanceIdentity, +): Promise { + const currentUrl = await connection.evaluate('String(globalThis.location.href)'); + if (typeof currentUrl !== 'string' || new URL(currentUrl).toString() !== new URL(identity.inspectedUrl).toString()) { + throw new Error('The inspected web preview URL changed while the performance request was running.'); + } + return currentUrl; +} + +export class WebPreviewTraceRecorder implements WebPreviewTraceRecorderLike { + private completionReject: ((error: Error) => void) | undefined; + private completionResolve: (() => void) | undefined; + private completionTimer: NodeJS.Timeout | undefined; + private readonly normalizer = new WebPreviewTraceNormalizer(); + private stopped = false; + private readonly unsubscribeClose: () => void; + private readonly unsubscribeEvents: () => void; + + private constructor( + private readonly connection: OwlChromiumConnection, + private readonly identity: WebPreviewPerformanceIdentity, + private readonly startMetrics: Record, + private readonly rendererTracing: boolean, + private readonly startedAtEpochMs: number, + ) { + this.unsubscribeEvents = connection.onEvent(event => { + if (event.method === 'Tracing.dataCollected') { + const values = event.params['value']; + if (!Array.isArray(values)) return; + for (const value of values) this.normalizer.accept(value); + return; + } + if (event.method === 'Tracing.tracingComplete') { + if (event.params['dataLossOccurred'] === true) this.normalizer.dataLossOccurred(); + this.completionResolve?.(); + } + }); + this.unsubscribeClose = connection.onClose(error => this.completionReject?.(error)); + } + + static async start(identity: WebPreviewPerformanceIdentity): Promise { + const connection = await connectToOwlApplication( + identity.debuggingPort, + identity.applicationUrl, + identity.targetNonce, + ); + try { + await assertCurrentTarget(connection, identity); + const currentUrl = await assertInspectedUrl(connection, identity); + await connection.call('Performance.enable', {}, CHROMIUM_COMMAND_TIMEOUT_MS); + const startMetrics = normalizeWebPreviewPerformanceMetrics( + await connection.call('Performance.getMetrics', {}, CHROMIUM_COMMAND_TIMEOUT_MS), + ); + const recorder = new WebPreviewTraceRecorder( + connection, + identity, + startMetrics, + rendererTracingEnabled(currentUrl), + Date.now(), + ); + try { + await connection.call( + 'Tracing.start', + { + categories: TRACE_CATEGORIES, + options: 'record-as-much-as-possible', + transferMode: 'ReportEvents', + }, + CHROMIUM_COMMAND_TIMEOUT_MS, + ); + } catch (error) { + const startError = error instanceof Error ? error : new Error(String(error)); + if (startError instanceof ChromiumDevToolsProtocolError) { + recorder.close(); + throw startError; + } + let cleanupError: Error | undefined; + try { + await recorder.stopForRecovery(); + } catch (error_) { + cleanupError = error_ instanceof Error ? error_ : new Error(String(error_)); + } + if (cleanupError) { + throw new WebPreviewTraceLifecycleError( + `${startError.message} Best-effort Chromium trace cleanup also failed: ${cleanupError.message}`, + false, + recorder, + ); + } + throw new WebPreviewTraceLifecycleError(startError.message, true, null); + } + return recorder; + } catch (error) { + connection.close(); + throw error; + } + } + + status(nowMs: number): WebPreviewTraceStatus { + return { + completedRecordingAvailable: false, + elapsedMs: Math.max(0, nowMs - this.startedAtEpochMs), + recording: true, + rendererTracingEnabled: this.rendererTracing, + startedAtEpochMs: this.startedAtEpochMs, + tracingSupported: true, + }; + } + + async stop(timedOut: boolean): Promise { + if (this.stopped) throw new Error('The web preview performance trace has already stopped.'); + this.stopped = true; + let traceEnded = false; + try { + await this.endTracing(); + traceEnded = true; + await assertCurrentTarget(this.connection, this.identity); + await assertInspectedUrl(this.connection, this.identity); + const endMetrics = normalizeWebPreviewPerformanceMetrics( + await this.connection.call('Performance.getMetrics', {}, CHROMIUM_COMMAND_TIMEOUT_MS), + ); + const normalized = this.normalizer.result(); + return { + browserMetrics: metricDifferences(this.startMetrics, endMetrics), + droppedTraceEventCount: normalized.droppedTraceEventCount, + elapsedMs: Math.max(0, Date.now() - this.startedAtEpochMs), + rendererTracingEnabled: this.rendererTracing, + startedAtEpochMs: this.startedAtEpochMs, + timedOut, + traces: normalized.traces, + }; + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)); + throw new WebPreviewTraceLifecycleError(normalized.message, traceEnded, null); + } finally { + this.close(); + } + } + + async stopForRecovery(): Promise { + if (this.stopped) throw new Error('The web preview performance trace has already stopped.'); + this.stopped = true; + try { + await this.endTracing(); + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)); + throw new WebPreviewTraceLifecycleError(normalized.message, false, null); + } finally { + this.close(); + } + } + + close(): void { + if (this.completionTimer !== undefined) { + clearTimeout(this.completionTimer); + this.completionTimer = undefined; + } + this.completionResolve = undefined; + this.completionReject = undefined; + this.unsubscribeEvents(); + this.unsubscribeClose(); + this.connection.close(); + } + + private async endTracing(): Promise { + const completed = new Promise((resolve, reject) => { + this.completionResolve = resolve; + this.completionReject = reject; + this.completionTimer = setTimeout(() => { + reject(new Error('Timed out waiting for the Chromium performance trace to finish.')); + }, TRACE_COMPLETION_TIMEOUT_MS); + }); + const completionError = completed.then( + () => null, + error => (error instanceof Error ? error : new Error(String(error))), + ); + + await this.connection.call('Tracing.end', {}, CHROMIUM_COMMAND_TIMEOUT_MS); + const error = await completionError; + if (error) throw error; + } +} + +function normalizedSnapshotData(value: unknown): Omit { + const record = asRecord(value) ?? {}; + const navigation = asRecord(record['navigation']) ?? {}; + const rawPaints = Array.isArray(record['paints']) ? record['paints'] : []; + const paints: Array<{ name: string; startTime: number }> = []; + for (const item of rawPaints.slice(0, MAX_PAINT_ENTRIES)) { + const paint = asRecord(item); + const name = typeof paint?.['name'] === 'string' ? paint['name'] : undefined; + const startTime = finiteNonNegativeNumber(paint?.['startTime']); + if (name !== undefined && Buffer.byteLength(name, 'utf8') <= MAX_PAINT_NAME_BYTES && startTime !== undefined) { + paints.push({ name, startTime }); + } + } + const domContentLoadedMs = finiteNonNegativeNumber(navigation['domContentLoadedMs']); + const loadMs = finiteNonNegativeNumber(navigation['loadMs']); + return { + navigation: { + ...(domContentLoadedMs === undefined ? {} : { domContentLoadedMs }), + ...(loadMs === undefined ? {} : { loadMs }), + }, + paints, + rendererTracingEnabled: record['rendererTracingEnabled'] === true, + resourceCount: boundedSafeInteger(record['resourceCount']) ?? 0, + transferSize: finiteNonNegativeNumber(record['transferSize']) ?? 0, + uptimeMs: finiteNonNegativeNumber(record['uptimeMs']) ?? 0, + }; +} + +export async function readWebPreviewPerformanceSnapshot( + identity: WebPreviewPerformanceIdentity, +): Promise { + const connection = await connectToOwlApplication( + identity.debuggingPort, + identity.applicationUrl, + identity.targetNonce, + ); + try { + await assertCurrentTarget(connection, identity); + await assertInspectedUrl(connection, identity); + await connection.call('Performance.enable', {}, CHROMIUM_COMMAND_TIMEOUT_MS); + const [snapshotValue, metricsValue] = await Promise.all([ + connection.evaluate(PERFORMANCE_SNAPSHOT_EXPRESSION), + connection.call('Performance.getMetrics', {}, CHROMIUM_COMMAND_TIMEOUT_MS), + ]); + await assertCurrentTarget(connection, identity); + await assertInspectedUrl(connection, identity); + const snapshot = normalizedSnapshotData(snapshotValue); + const metrics = normalizeWebPreviewPerformanceMetrics(metricsValue); + const heapUsedBytes = finiteNonNegativeNumber(metrics['JSHeapUsedSize']); + const heapTotalBytes = finiteNonNegativeNumber(metrics['JSHeapTotalSize']); + return { + ...snapshot, + mainThread: { + ...(finiteNonNegativeNumber(metrics['TaskDuration']) === undefined + ? {} + : { taskDurationMs: (metrics['TaskDuration'] ?? 0) * 1000 }), + ...(finiteNonNegativeNumber(metrics['ScriptDuration']) === undefined + ? {} + : { scriptDurationMs: (metrics['ScriptDuration'] ?? 0) * 1000 }), + ...(finiteNonNegativeNumber(metrics['LayoutDuration']) === undefined + ? {} + : { layoutDurationMs: (metrics['LayoutDuration'] ?? 0) * 1000 }), + }, + memory: + heapUsedBytes === undefined && heapTotalBytes === undefined + ? null + : { + ...(heapUsedBytes === undefined ? {} : { usedBytes: heapUsedBytes }), + ...(heapTotalBytes === undefined ? {} : { totalBytes: heapTotalBytes }), + }, + }; + } finally { + connection.close(); + } +} + +export async function enableWebPreviewRendererTracing(identity: WebPreviewPerformanceIdentity): Promise { + const connection = await connectToOwlApplication( + identity.debuggingPort, + identity.applicationUrl, + identity.targetNonce, + ); + try { + await assertCurrentTarget(connection, identity); + const currentUrl = await assertInspectedUrl(connection, identity); + const nextUrl = new URL(currentUrl); + nextUrl.searchParams.set('valdiDevTools', '1'); + nextUrl.searchParams.set('valdiTrace', 'chrome'); + await assertCurrentTarget(connection, identity); + await connection.call('Page.navigate', { url: nextUrl.toString() }, CHROMIUM_COMMAND_TIMEOUT_MS); + return nextUrl.toString(); + } finally { + connection.close(); + } +} + +function defaultDependencies(): WebPreviewPerformanceControllerDependencies { + return { + clearTimer: timer => clearTimeout(timer), + enableTracing: enableWebPreviewRendererTracing, + now: () => Date.now(), + readSnapshot: readWebPreviewPerformanceSnapshot, + setTimer: (callback, durationMs) => setTimeout(callback, durationMs), + startRecorder: identity => WebPreviewTraceRecorder.start(identity), + targetPresent: isWebPreviewPerformanceTargetPresent, + wait: async durationMs => + await new Promise(resolve => { + setTimeout(resolve, durationMs); + }), + }; +} + +export class WebPreviewPerformanceController { + private activeTrace: ActiveWebPreviewTrace | undefined; + private completion: TraceCompletion | undefined; + private completionExpiryTimer: NodeJS.Timeout | undefined; + private transitionTail: Promise = Promise.resolve(); + + constructor(private readonly dependencies: WebPreviewPerformanceControllerDependencies) {} + + async snapshot(identity: WebPreviewPerformanceIdentity): Promise { + return await this.dependencies.readSnapshot(identity); + } + + async enableTracing(identity: WebPreviewPerformanceIdentity): Promise { + return await this.runExclusive(async () => { + this.expireCompletion(); + await this.recoverMissingOwner(identity); + if (this.activeTrace || (this.completion && !this.completion.delivered)) { + throw new Error('Stop the current web preview performance trace before enabling renderer events.'); + } + return this.dependencies.enableTracing(identity); + }); + } + + async status(identity: WebPreviewPerformanceIdentity): Promise { + return await this.runExclusive(async () => { + this.expireCompletion(); + await this.recoverMissingOwner(identity); + if (this.activeTrace) { + this.assertIdentity(this.activeTrace.identity, identity); + if (this.activeTrace.cleanupError) throw this.activeTrace.cleanupError; + return this.activeTrace.recorder.status(this.dependencies.now()); + } + if (this.completion && !this.completion.delivered) { + this.assertIdentity(this.completion.identity, identity); + return { + completedRecordingAvailable: this.completion.capture !== undefined, + ...(this.completion.error ? { completionError: this.completion.error.message } : {}), + recording: false, + rendererTracingEnabled: rendererTracingEnabled(identity.inspectedUrl), + tracingSupported: true, + }; + } + return { + completedRecordingAvailable: false, + recording: false, + rendererTracingEnabled: rendererTracingEnabled(identity.inspectedUrl), + tracingSupported: true, + }; + }); + } + + async start(identity: WebPreviewPerformanceIdentity): Promise { + return await this.runExclusive(async () => await this.startTrace(identity, true)); + } + + async stop(identity: WebPreviewPerformanceIdentity): Promise { + return await this.runExclusive(async () => { + this.expireCompletion(); + await this.recoverMissingOwner(identity); + if (this.activeTrace) { + this.assertIdentity(this.activeTrace.identity, identity); + if (this.activeTrace.cleanupError) throw this.activeTrace.cleanupError; + return await this.finishActiveTrace(false, true); + } + if (this.completion) { + this.assertIdentity(this.completion.identity, identity); + if (this.completion.error) { + const error = this.completion.error; + this.clearCompletion(); + throw error; + } + if (!this.completion.capture) throw new Error('The completed Chromium trace is unavailable.'); + this.completion.delivered = true; + return this.completion.capture; + } + throw new Error('No Chromium performance trace is available for the inspected web preview.'); + }); + } + + async capture(identity: WebPreviewPerformanceIdentity, durationMs: number): Promise { + await this.runExclusive(async () => await this.startTrace(identity, false)); + try { + await this.dependencies.wait(durationMs); + } catch (error) { + try { + await this.stop(identity); + } catch (cleanupError) { + throw new Error( + `${error instanceof Error ? error.message : String(error)} Best-effort Chromium trace cleanup also failed: ${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }`, + ); + } + throw error; + } + return await this.stop(identity); + } + + async close(): Promise { + await this.runExclusive(async () => { + let closeError: Error | undefined; + if (this.activeTrace) { + const active = this.activeTrace; + this.activeTrace = undefined; + this.clearWatchdog(active.watchdog); + try { + await active.recorder.stop(false); + } catch (error) { + closeError = error instanceof Error ? error : new Error(String(error)); + } + } + this.clearCompletion(); + if (closeError) throw closeError; + }); + } + + private assertIdentity(expected: WebPreviewPerformanceIdentity, actual: WebPreviewPerformanceIdentity): void { + if (!identitiesMatch(expected, actual)) { + throw new Error('Another inspected web preview owns the current Chromium performance trace.'); + } + } + + private async finishActiveTrace(timedOut: boolean, delivered: boolean): Promise { + const active = this.activeTrace; + if (!active) throw new Error('No Chromium performance trace is recording.'); + this.clearWatchdog(active.watchdog); + active.watchdog = undefined; + try { + const capture = await active.recorder.stop(timedOut); + if (this.activeTrace === active) this.activeTrace = undefined; + this.retainCompletion({ capture, delivered, identity: active.identity }); + return capture; + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)); + if (error instanceof WebPreviewTraceLifecycleError && error.traceEnded) { + if (this.activeTrace === active) this.activeTrace = undefined; + this.retainCompletion({ delivered, error: normalized, identity: active.identity }); + } else { + active.cleanupError = normalized; + } + throw normalized; + } + } + + private async startTrace( + identity: WebPreviewPerformanceIdentity, + armWatchdog: boolean, + ): Promise { + this.expireCompletion(); + await this.recoverMissingOwner(identity); + if (this.activeTrace) { + if (this.activeTrace.cleanupError) throw this.activeTrace.cleanupError; + throw new Error('A Chromium performance trace is already recording.'); + } + if (this.completion && !this.completion.delivered) { + throw new Error('Retrieve the completed Chromium performance trace before starting another recording.'); + } + this.clearCompletion(); + let recorder: WebPreviewTraceRecorderLike; + try { + recorder = await this.dependencies.startRecorder(identity); + } catch (error) { + if (error instanceof WebPreviewTraceLifecycleError && !error.traceEnded && error.recorder) { + this.activeTrace = { + cleanupError: error, + identity, + recorder: error.recorder, + watchdog: undefined, + }; + } + throw error; + } + const watchdog = armWatchdog + ? this.dependencies.setTimer(() => { + void this.runExclusive(async () => { + if (!this.activeTrace || this.activeTrace.recorder !== recorder) return; + await this.finishActiveTrace(true, false); + }).catch(error => { + console.warn('[Valdi DevTools] Could not finalize the web preview performance trace.', error); + }); + }, WEB_PREVIEW_TRACE_WATCHDOG_MS) + : undefined; + this.activeTrace = { identity, recorder, watchdog }; + return recorder.status(this.dependencies.now()); + } + + private clearWatchdog(watchdog: NodeJS.Timeout | undefined): void { + if (watchdog !== undefined) this.dependencies.clearTimer(watchdog); + } + + private async recoverMissingOwner(identity: WebPreviewPerformanceIdentity): Promise { + const owner = + this.activeTrace?.identity ?? + (this.completion && !this.completion.delivered ? this.completion.identity : undefined); + if (!owner || identitiesMatch(owner, identity)) return; + if (await this.dependencies.targetPresent(owner)) { + throw new Error('Another inspected web preview owns the current Chromium performance trace.'); + } + + if (this.activeTrace) { + const active = this.activeTrace; + if (active.cleanupError) throw active.cleanupError; + this.clearWatchdog(active.watchdog); + try { + await active.recorder.stopForRecovery(); + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)); + active.cleanupError = new Error( + `Could not end the previous web preview performance trace after its inspected target disappeared: ${normalized.message}`, + ); + throw active.cleanupError; + } + if (this.activeTrace === active) this.activeTrace = undefined; + } + this.clearCompletion(); + } + + private retainCompletion(value: Omit): void { + this.clearCompletion(); + const expiresAtMs = this.dependencies.now() + WEB_PREVIEW_TRACE_RESULT_TTL_MS; + this.completion = { ...value, expiresAtMs }; + this.completionExpiryTimer = this.dependencies.setTimer(() => { + void this.runExclusive(() => this.expireCompletion()).catch(error => { + console.warn('[Valdi DevTools] Could not expire the web preview performance result.', error); + }); + }, WEB_PREVIEW_TRACE_RESULT_TTL_MS); + } + + private expireCompletion(): void { + if (this.completion && this.completion.expiresAtMs <= this.dependencies.now()) this.clearCompletion(); + } + + private clearCompletion(): void { + if (this.completionExpiryTimer !== undefined) { + this.dependencies.clearTimer(this.completionExpiryTimer); + this.completionExpiryTimer = undefined; + } + this.completion = undefined; + } + + private async runExclusive(operation: () => Promise | Value): Promise { + const previous = this.transitionTail; + const gate = createTransitionGate(); + this.transitionTail = gate.promise; + await previous; + try { + return await operation(); + } finally { + gate.resolve(); + } + } +} + +export function createWebPreviewPerformanceController(): WebPreviewPerformanceController { + return new WebPreviewPerformanceController(defaultDependencies()); +} diff --git a/npm_modules/cli/src/utils/chromiumDevToolsClient.ts b/npm_modules/cli/src/utils/chromiumDevToolsClient.ts index 11501530..ff3308d0 100644 --- a/npm_modules/cli/src/utils/chromiumDevToolsClient.ts +++ b/npm_modules/cli/src/utils/chromiumDevToolsClient.ts @@ -30,6 +30,13 @@ export interface ChromiumDevToolsEvent { params: Record; } +export class ChromiumDevToolsProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = 'ChromiumDevToolsProtocolError'; + } +} + function encodeWebSocketFrame(opcode: number, payload: Buffer): Buffer { const mask = crypto.randomBytes(4); let header: Buffer; @@ -342,7 +349,11 @@ export class ChromiumDevToolsConnection { this.pending.delete(response.id); clearTimeout(command.timer); if (response.error) { - command.reject(new Error(response.error.message ?? 'The Chromium DevTools target rejected the request.')); + command.reject( + new ChromiumDevToolsProtocolError( + response.error.message ?? 'The Chromium DevTools target rejected the request.', + ), + ); return; } command.resolve(response.result);