-
+
-
+
+
+ Web preview performance + +
+
+
Open Performance to inspect the selected web preview.
+
+
+ +
diff --git a/npm_modules/cli/debugger/devtools-panel.js b/npm_modules/cli/debugger/devtools-panel.js index 6173b05b..7fc0ee7a 100644 --- a/npm_modules/cli/debugger/devtools-panel.js +++ b/npm_modules/cli/debugger/devtools-panel.js @@ -4,6 +4,9 @@ const inspectedTargetNonce = query.get('targetNonce'); const MAX_CONSOLE_ENTRIES = 500; const MAX_CONSOLE_ENTRY_CHARACTERS = 50_000; const MAX_CONSOLE_HISTORY_ENTRIES = 100; +const MAX_PERFORMANCE_SAMPLES = 120; +const MAX_PERFORMANCE_TIMELINE_ROWS = 120; +const MAX_PERFORMANCE_SUMMARY_ROWS = 12; const state = { target: null, @@ -31,6 +34,23 @@ const state = { consoleHistoryIndex: 0, consoleStream: null, consoleStreamTargetKey: null, + performance: { + data: null, + durationSeconds: 3, + error: null, + lastTrace: null, + navigationExpanded: false, + operationGeneration: 0, + ownerIdentity: null, + pending: false, + rendererTracingEnabled: false, + requestGeneration: 0, + samples: [], + snapshotPending: false, + traceActive: false, + traceScope: 'valdi', + traceSearch: '', + }, error: null, }; @@ -57,6 +77,7 @@ const elements = { consoleMessages: document.getElementById('consoleMessages'), consoleForm: document.getElementById('consoleForm'), consoleInput: document.getElementById('consoleInput'), + performanceContent: document.getElementById('performanceContent'), }; function escapeHtml(value) { @@ -97,6 +118,19 @@ async function requestJson(path, params, options) { return payload; } +function stopPerformanceOnPageHide() { + const identity = state.performance.ownerIdentity; + if (!identity) return; + const url = new URL('/api/devtools/performance/trace/stop', window.location.origin); + for (const [key, value] of Object.entries(identity)) url.searchParams.set(key, String(value)); + void fetch(url, { + body: '{}', + headers: { 'Content-Type': 'application/json' }, + keepalive: true, + method: 'POST', + }).catch(error => console.warn('Unable to stop the web preview performance trace while closing DevTools.', error)); +} + function formatNumber(value) { const numeric = Number(value); if (!Number.isFinite(numeric)) return '—'; @@ -105,6 +139,79 @@ function formatNumber(value) { : numeric.toLocaleString(undefined, { maximumFractionDigits: 1 }); } +function formatBytes(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0) return '—'; + if (numeric < 1024) return `${formatNumber(numeric)} B`; + if (numeric < 1024 * 1024) return `${formatNumber(numeric / 1024)} KiB`; + return `${formatNumber(numeric / (1024 * 1024))} MiB`; +} + +function formatDuration(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0) return '—'; + if (numeric < 1) return `${formatNumber(numeric * 1000)} µs`; + if (numeric < 1000) return `${formatNumber(numeric)} ms`; + return `${formatNumber(numeric / 1000)} s`; +} + +function formatUptime(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0) return '—'; + if (numeric < 60_000) return `${formatNumber(numeric / 1000)} s`; + return `${formatNumber(numeric / 60_000)} min`; +} + +function performanceIdentity(target = state.target) { + if (!target?.sessionId || !inspectedUrl || !inspectedTargetNonce) { + throw new Error('The selected web preview does not have a complete performance identity.'); + } + return { + inspectedUrl, + sessionId: target.sessionId, + targetNonce: inspectedTargetNonce, + }; +} + +function samePerformanceIdentity(left, right) { + return Boolean( + left && + right && + left.sessionId === right.sessionId && + left.inspectedUrl === right.inspectedUrl && + left.targetNonce === right.targetNonce, + ); +} + +function performanceIdentityIsCurrent(identity) { + try { + return samePerformanceIdentity(identity, performanceIdentity()); + } catch { + return false; + } +} + +function performancePollingInputIsFocused() { + return ['performanceDurationInput', 'performanceTraceFilter'].includes(document.activeElement?.id); +} + +function preparePerformanceForTargetChange() { + const perf = state.performance; + perf.requestGeneration++; + perf.operationGeneration++; + perf.snapshotPending = false; + perf.pending = false; + perf.data = null; + perf.lastTrace = null; + perf.rendererTracingEnabled = false; + perf.samples = []; + if (perf.traceActive || perf.ownerIdentity) { + perf.error = 'The previous web preview still owns a performance recording. Stop and retrieve it before switching.'; + return; + } + perf.error = null; +} + function nodeAttributes(node) { return valdiDebuggerTreeModel.attributes(node); } @@ -236,6 +343,7 @@ async function connectToInspectedApplication() { state.consoleEntries = []; state.consoleEntryKeys.clear(); elements.consoleMessages.innerHTML = ''; + preparePerformanceForTargetChange(); } state.target = payload.target; elements.targetName.textContent = state.target.name || 'Valdi application'; @@ -304,8 +412,9 @@ async function refreshSnapshot() { function startRefreshTimer() { if (state.refreshTimer) window.clearInterval(state.refreshTimer); state.refreshTimer = window.setInterval(() => { - if (!state.autoRefresh || document.hidden || state.activeSection !== 'elements') return; - void refreshSnapshot(); + if (!state.autoRefresh || document.hidden) return; + if (state.activeSection === 'elements') void refreshSnapshot(); + if (state.activeSection === 'performance') void refreshPerformance({ silent: true }); }, 1200); } @@ -649,6 +758,528 @@ function queueHighlight(nodeIdValue) { ); } +function renderPerformanceMetric(label, value) { + return `
${escapeHtml(label)}
${escapeHtml(value)}
`; +} + +function recordPerformanceSample(data) { + const uptimeMs = Number(data?.uptimeMs); + if (!Number.isFinite(uptimeMs) || uptimeMs < 0) return; + const samples = state.performance.samples; + const previous = samples[samples.length - 1]; + if (previous?.uptimeMs === uptimeMs) return; + if (previous && previous.uptimeMs > uptimeMs) samples.length = 0; + const sample = { uptimeMs }; + for (const [property, value] of [ + ['heapUsedBytes', data?.memory?.usedBytes], + ['layoutDurationMs', data?.mainThread?.layoutDurationMs], + ['resourceCount', data?.resourceCount], + ['scriptDurationMs', data?.mainThread?.scriptDurationMs], + ['taskDurationMs', data?.mainThread?.taskDurationMs], + ]) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) sample[property] = numeric; + } + samples.push(sample); + if (samples.length > MAX_PERFORMANCE_SAMPLES) { + samples.splice(0, samples.length - MAX_PERFORMANCE_SAMPLES); + } +} + +function performanceGraphPath(points, minimum, maximum) { + if (!points.length) return ''; + const firstTime = points[0].time; + const lastTime = points[points.length - 1].time; + const timeSpan = Math.max(lastTime - firstTime, 1); + const valueSpan = Math.max(maximum - minimum, 1); + return points + .map((point, index) => { + const x = points.length === 1 ? 100 : ((point.time - firstTime) / timeSpan) * 100; + const y = 27 - Math.max(0, Math.min(1, (point.value - minimum) / valueSpan)) * 23; + return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`; + }) + .join(' '); +} + +function renderSampledPerformanceMetric(label, kind, property, formattedValue) { + const points = state.performance.samples + .filter(sample => Number.isFinite(sample[property])) + .map(sample => ({ time: sample.uptimeMs, value: sample[property] })); + if (!points.length) return renderPerformanceMetric(label, formattedValue); + const minimum = Math.min(...points.map(point => point.value)); + const maximum = Math.max(...points.map(point => point.value)); + const padding = Math.max((maximum - minimum) * 0.12, maximum * 0.015, 1); + const path = performanceGraphPath(points, Math.max(0, minimum - padding), maximum + padding); + return ` +
+
${escapeHtml(label)}
+
${escapeHtml(formattedValue)}
+ + + + + +
+ `; +} + +function performanceScopeMatches(trace, scope) { + const name = String(trace?.trace || ''); + if (scope === 'valdi') return name.startsWith('Valdi.'); + if (scope === 'browser') return name.startsWith('Browser.'); + return name.startsWith('Valdi.') || name.startsWith('Browser.'); +} + +function filteredPerformanceTraces(result) { + const search = String(state.performance.traceSearch || '') + .trim() + .toLowerCase(); + const traces = Array.isArray(result?.traces) ? result.traces : []; + return traces.filter( + trace => + performanceScopeMatches(trace, state.performance.traceScope) && + (!search || + String(trace.trace || '') + .toLowerCase() + .includes(search)), + ); +} + +function performanceScopeCounts(traces) { + const counts = { all: 0, browser: 0, valdi: 0 }; + for (const trace of traces) { + if (!performanceScopeMatches(trace, 'all')) continue; + counts.all++; + if (performanceScopeMatches(trace, 'valdi')) counts.valdi++; + if (performanceScopeMatches(trace, 'browser')) counts.browser++; + } + return counts; +} + +function performanceLane(trace) { + const name = String(trace?.trace || ''); + if (name.startsWith('Valdi.')) return 'valdi'; + if (name.startsWith('Browser.Layout.')) return 'layout'; + if (name.startsWith('Browser.Paint.')) return 'paint'; + if (name.startsWith('Browser.Frames.')) return 'frames'; + if (name.startsWith('Browser.GC.')) return 'gc'; + return 'script'; +} + +function renderPerformanceTimeline(result) { + const traces = Array.isArray(result?.traces) ? result.traces : []; + const counts = performanceScopeCounts(traces); + const scopes = [ + ['valdi', 'Valdi'], + ['browser', 'Browser'], + ['all', 'All'], + ]; + const controls = ` +
+
+ ${scopes + .map( + ([scope, label]) => + ``, + ) + .join('')} +
+ +
+ `; + if (!traces.length) { + return `${controls}
Record an interaction to inspect browser and Valdi renderer events.
`; + } + const filtered = filteredPerformanceTraces(result); + if (!filtered.length) return `${controls}
No captured events match this filter.
`; + + let firstTimestamp = Infinity; + let lastTimestamp = -Infinity; + for (const trace of filtered) { + firstTimestamp = Math.min(firstTimestamp, Number(trace.startMicros)); + lastTimestamp = Math.max(lastTimestamp, Number(trace.endMicros)); + } + const spanMicros = Math.max(lastTimestamp - firstTimestamp, 1000); + const displayed = filtered.slice(0, MAX_PERFORMANCE_TIMELINE_ROWS); + const rows = displayed + .map(trace => { + const startMicros = Number(trace.startMicros); + const durationMicros = Math.max(0, Number(trace.endMicros) - startMicros); + const offset = Math.max(0, Math.min(100, ((startMicros - firstTimestamp) / spanMicros) * 100)); + const width = Math.max(0.65, Math.min(100 - offset, (durationMicros / spanMicros) * 100)); + const duration = trace.type === 1 ? 'instant' : formatDuration(durationMicros / 1000); + const name = String(trace.trace || '').replace(/^Browser\./, ''); + return ` +
+ ${escapeHtml(name)} + + ${escapeHtml(duration)} +
+ `; + }) + .join(''); + const truncated = + filtered.length > displayed.length + ? `
Showing ${formatNumber(displayed.length)} of ${formatNumber(filtered.length)} matching events. Export includes every bounded event.
` + : ''; + return ` + ${controls} +
+ Valdi + JavaScript + Layout + Paint + Frames + GC +
+
${rows}
+ ${truncated} + `; +} + +function renderPerformanceSummary(result) { + const grouped = new Map(); + for (const trace of filteredPerformanceTraces(result)) { + const name = String(trace.trace || ''); + const event = grouped.get(name) || { count: 0, durationMs: 0, name }; + event.count++; + event.durationMs += Math.max(0, Number(trace.endMicros) - Number(trace.startMicros)) / 1000; + grouped.set(name, event); + } + const rows = Array.from(grouped.values()) + .sort((left, right) => right.durationMs - left.durationMs || right.count - left.count) + .slice(0, MAX_PERFORMANCE_SUMMARY_ROWS) + .map( + event => + `${escapeHtml(event.name)}${escapeHtml(formatNumber(event.count))}${escapeHtml(formatDuration(event.durationMs))}`, + ) + .join(''); + if (!rows) return ''; + return ` +
Total captured duration by event
+ ${rows}
OperationCallsInclusive total
+ `; +} + +function performanceButton(label, action, options = {}) { + return ``; +} + +function replacePerformanceContent(html) { + const contentScrollTop = elements.performanceContent.scrollTop; + const previousTimeline = elements.performanceContent.querySelector?.('.performance-timeline'); + const timelineScrollLeft = previousTimeline?.scrollLeft ?? 0; + const timelineScrollTop = previousTimeline?.scrollTop ?? 0; + elements.performanceContent.innerHTML = html; + elements.performanceContent.scrollTop = contentScrollTop; + const nextTimeline = elements.performanceContent.querySelector?.('.performance-timeline'); + if (nextTimeline) { + nextTimeline.scrollLeft = timelineScrollLeft; + nextTimeline.scrollTop = timelineScrollTop; + } +} + +function renderPerformance(data = state.performance.data) { + const perf = state.performance; + if (!data) { + const error = perf.error ? `` : ''; + const ownerRecovery = perf.ownerIdentity + ? `
Previous web preview recording${perf.traceActive ? 'Recording' : 'Result pending'}
${performanceButton('Stop and retrieve', 'trace-stop', { disabled: perf.pending, primary: true })}
` + : ''; + replacePerformanceContent( + error || ownerRecovery + ? `${error}${ownerRecovery}` + : '
Loading web preview performance…
', + ); + return; + } + perf.data = data; + recordPerformanceSample(data); + const browserMetrics = perf.lastTrace?.browserMetrics || {}; + const browserSummary = perf.lastTrace?.browserSummary || {}; + const metrics = [ + renderSampledPerformanceMetric('JS heap', 'heap', 'heapUsedBytes', formatBytes(data.memory?.usedBytes)), + renderSampledPerformanceMetric( + 'Main-thread time (page lifetime)', + 'main-thread', + 'taskDurationMs', + formatDuration(data.mainThread?.taskDurationMs), + ), + renderSampledPerformanceMetric( + 'JavaScript time (page lifetime)', + 'script', + 'scriptDurationMs', + formatDuration(data.mainThread?.scriptDurationMs), + ), + renderSampledPerformanceMetric( + 'Layout time (page lifetime)', + 'layout', + 'layoutDurationMs', + formatDuration(data.mainThread?.layoutDurationMs), + ), + renderSampledPerformanceMetric('Resources', 'resources', 'resourceCount', formatNumber(data.resourceCount)), + renderPerformanceMetric('Page uptime', formatUptime(data.uptimeMs)), + ]; + if (perf.lastTrace) { + metrics.push( + renderPerformanceMetric('Captured events', formatNumber(perf.lastTrace.traceCount)), + renderPerformanceMetric('Long tasks', formatNumber(browserSummary.longTaskCount)), + renderPerformanceMetric('Layout passes', formatNumber(browserMetrics.LayoutCount)), + ); + } + const hasTraceOwner = Boolean(perf.traceActive || perf.ownerIdentity); + const rendererStatus = perf.rendererTracingEnabled + ? 'Valdi renderer events enabled' + : 'Valdi renderer events disabled'; + const rendererNotice = perf.rendererTracingEnabled + ? '' + : `
Browser events can be recorded now. Valdi renderer events require reloading the inspected page; reloading can reset page state. ${performanceButton('Enable renderer events', 'enable-tracing', { disabled: perf.pending || hasTraceOwner })}
`; + const traceStatus = `${perf.traceActive ? 'Recording' : perf.ownerIdentity ? 'Result pending' : 'Idle'}`; + const traceCaption = perf.lastTrace + ? `
${escapeHtml(formatNumber(perf.lastTrace.traceCount))} events · ${escapeHtml(formatDuration(perf.lastTrace.elapsedMs))}${perf.lastTrace.droppedTraceEventCount ? ` · ${escapeHtml(formatNumber(perf.lastTrace.droppedTraceEventCount))} dropped` : ''}
` + : ''; + const paints = (Array.isArray(data.paints) ? data.paints : []) + .map(paint => `${escapeHtml(paint.name)}${escapeHtml(formatDuration(paint.startTime))}`) + .join(''); + replacePerformanceContent(` +
${metrics.join('')}
+
Main-thread, JavaScript, and layout counters are cumulative for the current page lifetime.
+ ${perf.error ? `` : ''} +
+
Rendering and main-thread trace${rendererStatus}
${traceStatus}
+
+ + ${performanceButton('Start', 'trace-start', { disabled: perf.pending || hasTraceOwner })} + ${performanceButton('Stop', 'trace-stop', { disabled: perf.pending || !hasTraceOwner, primary: hasTraceOwner })} + ${performanceButton('Capture', 'trace-capture', { disabled: perf.pending || hasTraceOwner, primary: !hasTraceOwner })} + ${performanceButton('Export trace', 'trace-export', { disabled: !perf.lastTrace })} +
+ ${rendererNotice} + ${traceCaption} + ${renderPerformanceTimeline(perf.lastTrace)} + ${renderPerformanceSummary(perf.lastTrace)} +
+
+ Initial page-load milestones +
DOM ready ${escapeHtml(formatDuration(data.navigation?.domContentLoadedMs))} · Page load ${escapeHtml(formatDuration(data.navigation?.loadMs))} · Transferred ${escapeHtml(formatBytes(data.transferSize))}
+ ${paints ? `${paints}
` : '
No paint milestones have been reported.
'} +
+ `); +} + +function buildPerformanceTraceExport(result) { + const traces = Array.isArray(result?.traces) ? result.traces : []; + const firstTimestamp = traces.reduce( + (minimum, trace) => Math.min(minimum, Number(trace.startMicros)), + Number(traces[0]?.startMicros || 0), + ); + const threadIds = Array.from(new Set(traces.map(trace => Number(trace.threadId)))).slice(0, 256); + const traceEvents = [ + { args: { name: 'Valdi web preview' }, name: 'process_name', ph: 'M', pid: 1 }, + ...threadIds.map(threadId => ({ + args: { name: `Thread ${threadId}` }, + name: 'thread_name', + ph: 'M', + pid: 1, + tid: threadId, + })), + ]; + for (const trace of traces) { + const instant = trace.type === 1; + const event = { + cat: String(trace.trace || '').startsWith('Valdi.') ? 'valdi' : 'browser', + name: String(trace.trace || ''), + ph: instant ? 'i' : 'X', + pid: 1, + tid: Number(trace.threadId), + ts: Number(trace.startMicros) - firstTimestamp, + }; + if (instant) event.s = 't'; + else event.dur = Math.max(0, Number(trace.endMicros) - Number(trace.startMicros)); + traceEvents.push(event); + } + return { + displayTimeUnit: 'ms', + metadata: result?.perfettoMetadata || {}, + traceEvents, + }; +} + +function downloadPerformanceTrace(result) { + const blob = new Blob([JSON.stringify(buildPerformanceTraceExport(result), null, 2)], { + type: 'application/json', + }); + const artifactUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = artifactUrl; + link.download = `valdi-web-preview-${Date.now()}.trace.json`; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(artifactUrl), 1000); +} + +async function refreshPerformance(options = {}) { + if (options.silent && performancePollingInputIsFocused()) return; + if (!state.target || state.performance.pending || state.performance.snapshotPending) return; + const perf = state.performance; + const identity = performanceIdentity(); + const requestGeneration = ++perf.requestGeneration; + const requestIsCurrent = () => requestGeneration === perf.requestGeneration && performanceIdentityIsCurrent(identity); + perf.snapshotPending = true; + if (!options.silent && !perf.data) renderPerformance(); + try { + const [data, status] = await Promise.all([ + requestJson('/api/devtools/performance/snapshot', identity, {}), + requestJson('/api/devtools/performance/trace/status', identity, {}), + ]); + if (status.completionError) { + try { + await requestJson('/api/devtools/performance/trace/stop', identity, { body: {} }); + } catch { + // Stop surfaces the retained completion error after clearing it on the server. + } + if (requestIsCurrent() && (!perf.ownerIdentity || samePerformanceIdentity(perf.ownerIdentity, identity))) { + perf.traceActive = false; + perf.ownerIdentity = null; + } + throw new Error(status.completionError); + } + let completedTrace = null; + if (status.completedRecordingAvailable) { + completedTrace = await requestJson('/api/devtools/performance/trace/stop', identity, { body: {} }); + } + if (!requestIsCurrent()) return; + perf.data = data; + if (!perf.ownerIdentity || samePerformanceIdentity(perf.ownerIdentity, identity)) { + perf.traceActive = Boolean(status.recording); + perf.ownerIdentity = perf.traceActive ? identity : null; + } + perf.rendererTracingEnabled = Boolean(data.rendererTracingEnabled || status.rendererTracingEnabled); + if (completedTrace) { + perf.traceActive = false; + perf.ownerIdentity = null; + perf.lastTrace = completedTrace; + } + perf.error = null; + renderPerformance(data); + } catch (error) { + if (requestIsCurrent()) { + perf.error = error instanceof Error ? error.message : String(error); + renderPerformance(perf.data); + } + } finally { + if (requestIsCurrent()) perf.snapshotPending = false; + } +} + +async function runPerformanceAction(action) { + const perf = state.performance; + if (action === 'refresh') { + await refreshPerformance(); + return; + } + if (action === 'trace-export') { + if (perf.lastTrace) downloadPerformanceTrace(perf.lastTrace); + return; + } + if (!state.target || perf.pending) return; + if (['enable-tracing', 'trace-capture', 'trace-start'].includes(action) && (perf.traceActive || perf.ownerIdentity)) { + return; + } + if ( + action === 'enable-tracing' && + !window.confirm('Enable Valdi renderer events? This reloads the inspected page and can reset page state.') + ) { + return; + } + + const selectedIdentity = performanceIdentity(); + const identity = action === 'trace-stop' && perf.ownerIdentity ? perf.ownerIdentity : selectedIdentity; + perf.requestGeneration++; + perf.snapshotPending = false; + const operationGeneration = ++perf.operationGeneration; + const selectedTargetIsCurrent = () => + operationGeneration === perf.operationGeneration && performanceIdentityIsCurrent(selectedIdentity); + const operationOwnsTrace = () => samePerformanceIdentity(perf.ownerIdentity, identity); + let refreshSelectedTarget = false; + perf.pending = true; + perf.error = null; + renderPerformance(perf.data); + try { + if (action === 'enable-tracing') { + await requestJson('/api/devtools/performance/trace/enable', identity, { body: {} }); + if (!selectedTargetIsCurrent()) return; + perf.rendererTracingEnabled = true; + addConsoleEntry('info', 'Reloaded the inspected page with Valdi renderer events enabled.'); + } else { + const operation = action.slice('trace-'.length); + const durationMs = Math.round(Math.max(0.1, Math.min(15, Number(perf.durationSeconds) || 3)) * 1000); + if (operation === 'capture') { + perf.traceActive = true; + perf.ownerIdentity = identity; + renderPerformance(perf.data); + } + const result = await requestJson(`/api/devtools/performance/trace/${operation}`, identity, { + body: operation === 'capture' ? { durationMs } : {}, + }); + if (operation === 'start') { + if (!selectedTargetIsCurrent()) { + if (result.recording) { + try { + await requestJson('/api/devtools/performance/trace/stop', identity, { body: {} }); + } catch (cleanupError) { + if (!perf.ownerIdentity || samePerformanceIdentity(perf.ownerIdentity, identity)) { + perf.traceActive = true; + perf.ownerIdentity = identity; + perf.error = `The previous web preview still owns a performance recording: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`; + if (state.activeSection === 'performance') renderPerformance(perf.data); + } + } + } + return; + } + perf.traceActive = Boolean(result.recording); + perf.ownerIdentity = perf.traceActive ? identity : null; + refreshSelectedTarget = true; + } else { + const completedOwnedTrace = ['capture', 'stop'].includes(operation) && operationOwnsTrace(); + const completedPreviousOwner = completedOwnedTrace && !samePerformanceIdentity(identity, selectedIdentity); + if (completedOwnedTrace) { + perf.traceActive = false; + perf.ownerIdentity = null; + } + if (completedPreviousOwner) { + perf.data = null; + perf.lastTrace = null; + perf.rendererTracingEnabled = false; + perf.samples = []; + } + if (selectedTargetIsCurrent() && !completedPreviousOwner) { + perf.traceActive = false; + perf.ownerIdentity = null; + perf.lastTrace = result; + } + refreshSelectedTarget = selectedTargetIsCurrent(); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (selectedTargetIsCurrent() || operationOwnsTrace()) { + perf.error = message; + if (state.activeSection === 'performance') renderPerformance(perf.data); + } else { + console.warn('Ignoring a stale web preview performance action error.', error); + } + } finally { + if (selectedTargetIsCurrent()) { + perf.pending = false; + if (state.activeSection === 'performance') renderPerformance(perf.data); + } + } + if (refreshSelectedTarget && selectedTargetIsCurrent() && state.target) { + await refreshPerformance({ silent: true }); + } +} + function setActiveSection(section) { state.activeSection = section; for (const tab of elements.mainTabs) { @@ -661,6 +1292,7 @@ function setActiveSection(section) { } if (section === 'console') elements.consoleInput.focus(); if (section === 'elements') void refreshSnapshot(); + if (section === 'performance') void refreshPerformance(); } function setActiveDetail(detail) { @@ -823,7 +1455,46 @@ function wireEvents() { for (const tab of elements.detailTabs) { tab.addEventListener('click', () => setActiveDetail(tab.dataset.detail)); } - elements.refreshButton.addEventListener('click', () => void refreshSnapshot()); + elements.refreshButton.addEventListener('click', () => { + if (state.activeSection === 'performance') void refreshPerformance(); + else void refreshSnapshot(); + }); + for (const button of document.querySelectorAll('.section-header [data-performance-action]')) { + button.addEventListener('click', () => void runPerformanceAction(button.dataset.performanceAction)); + } + elements.performanceContent.addEventListener('click', event => { + const scope = event.target.closest('[data-performance-scope]'); + if (scope) { + state.performance.traceScope = scope.dataset.performanceScope; + renderPerformance(); + return; + } + const button = event.target.closest('[data-performance-action]'); + if (button && !button.disabled) void runPerformanceAction(button.dataset.performanceAction); + }); + elements.performanceContent.addEventListener('input', event => { + if (event.target.id === 'performanceDurationInput') { + state.performance.durationSeconds = Math.max(0.1, Math.min(15, Number(event.target.value) || 3)); + return; + } + if (event.target.id !== 'performanceTraceFilter') return; + const selectionStart = event.target.selectionStart; + const selectionEnd = event.target.selectionEnd; + state.performance.traceSearch = event.target.value; + renderPerformance(); + const filter = document.getElementById('performanceTraceFilter'); + filter?.focus(); + if (selectionStart !== null && selectionEnd !== null) filter?.setSelectionRange(selectionStart, selectionEnd); + }); + elements.performanceContent.addEventListener( + 'toggle', + event => { + if (event.target?.tagName === 'DETAILS' && event.target.classList.contains('performance-navigation')) { + state.performance.navigationExpanded = event.target.open; + } + }, + true, + ); elements.autoRefreshToggle.addEventListener('change', () => { state.autoRefresh = elements.autoRefreshToggle.checked; if (state.autoRefresh) { @@ -905,9 +1576,13 @@ function wireEvents() { applyTheme(event.data.theme); } }); - window.addEventListener('pagehide', stopConsoleStream); + window.addEventListener('pagehide', () => { + stopConsoleStream(); + stopPerformanceOnPageHide(); + }); document.addEventListener('visibilitychange', () => { if (!document.hidden && state.activeSection === 'elements') void refreshSnapshot(); + if (!document.hidden && state.activeSection === 'performance') void refreshPerformance({ silent: true }); }); } diff --git a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts index 8fc91933..71d2744c 100644 --- a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts +++ b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts @@ -602,3 +602,526 @@ describe('integrated DevTools console panel', () => { ]); }); }); + +interface DevToolsPerformancePanel { + document: { activeElement: { id?: string } | null }; + performanceContent: { + innerHTML: string; + scrollTop: number; + querySelector(selector: string): { scrollLeft: number; scrollTop: number } | null; + }; + state: { + activeSection: string; + performance: { + data: Record | null; + durationSeconds: number; + error: string | null; + lastTrace: Record | null; + ownerIdentity: Record | null; + pending: boolean; + samples: Array>; + snapshotPending: boolean; + traceActive: boolean; + traceScope: string; + traceSearch: string; + }; + target: { id: string; sessionId: string } | null; + }; + buildPerformanceTraceExport(result: Record): { traceEvents: Array> }; + dispatchWindowEvent(type: string): void; + preparePerformanceForTargetChange(): void; + refreshPerformance(options?: Record): Promise; + renderPerformance(data?: Record): void; + runPerformanceAction(action: string): Promise; +} + +describe('integrated DevTools performance panel', () => { + let panel: DevToolsPerformancePanel; + let requests: Array<{ body?: string; keepalive?: boolean; method: string; url: string }>; + let nextFetchResponse: Promise<{ ok: boolean; status: number; json(): Promise> }> | undefined; + let traceRecording: boolean; + let completionErrorPending: boolean; + let stopFailure: string | null; + let snapshotResponse: Record; + + const snapshot = { + mainThread: { layoutDurationMs: 2, scriptDurationMs: 4, taskDurationMs: 12 }, + memory: { totalBytes: 4096, usedBytes: 2048 }, + navigation: { domContentLoadedMs: 30, loadMs: 50 }, + paints: [{ name: 'first-contentful-paint', startTime: 25 }], + rendererTracingEnabled: true, + resourceCount: 4, + transferSize: 1024, + uptimeMs: 100, + }; + + function traceResult(): Record { + return { + browserMetrics: { LayoutCount: 2, TaskDurationMs: 12 }, + browserSummary: { browserEventCount: 1, longTaskCount: 1, rendererEventCount: 1 }, + droppedTraceEventCount: 0, + elapsedMs: 100, + perfettoMetadata: { captureScope: 'process-wide' }, + recording: false, + traceCount: 2, + traces: [ + { endMicros: 1300, startMicros: 1000, threadId: 1, trace: 'Valdi.Renderer.onRender.Example' }, + { endMicros: 76_500, startMicros: 1500, threadId: 1, trace: 'Browser.MainThread.Task' }, + ], + }; + } + + beforeEach(() => { + requests = []; + nextFetchResponse = undefined; + traceRecording = false; + completionErrorPending = false; + stopFailure = null; + snapshotResponse = snapshot; + const treeModelSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8'); + const rawPanelSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'devtools-panel.js'), 'utf8'); + const panelSource = rawPanelSource.replace('void connectToInspectedApplication();', 'void 0;'); + const elements = new Map>(); + const document = { + activeElement: null as { id?: string } | null, + addEventListener() {}, + createElement() { + return { click() {} }; + }, + documentElement: { dataset: {} }, + getElementById(id: string): Record { + let element = elements.get(id); + if (element === undefined) { + element = { + checked: true, + className: '', + classList: { contains: () => false, toggle() {} }, + dataset: {}, + innerHTML: '', + scrollHeight: 0, + scrollTop: 0, + style: {}, + textContent: '', + value: '', + addEventListener() {}, + contains: () => false, + focus() {}, + removeAttribute() {}, + querySelector: () => null, + setAttribute() {}, + setSelectionRange() {}, + }; + elements.set(id, element); + } + return element; + }, + querySelectorAll(): unknown[] { + return []; + }, + }; + const windowListeners = new Map void>>(); + const window = { + addEventListener(type: string, listener: () => void) { + const listeners = windowListeners.get(type) ?? []; + listeners.push(listener); + windowListeners.set(type, listeners); + }, + clearInterval() {}, + clearTimeout() {}, + confirm: () => true, + location: { + origin: 'http://127.0.0.1:18768', + search: + '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456', + }, + parent: {}, + dispatch(type: string) { + for (const listener of windowListeners.get(type) ?? []) listener(); + }, + setInterval: () => 1, + setTimeout: () => 1, + }; + + panel = new Script( + `${treeModelSource}\n${panelSource}\n({ buildPerformanceTraceExport, dispatchWindowEvent: type => window.dispatch(type), document, performanceContent: elements.performanceContent, preparePerformanceForTargetChange, refreshPerformance, renderPerformance, runPerformanceAction, state })`, + ).runInNewContext({ + Blob, + Date, + EventSource: class { + addEventListener() {} + close() {} + }, + URL, + URLSearchParams, + console, + document, + elements, + fetch: (url: URL, options: { body?: string; keepalive?: boolean; method: string }) => { + const requestUrl = new URL(url.toString()); + requests.push({ + ...(options.body === undefined ? {} : { body: options.body }), + ...(options.keepalive === undefined ? {} : { keepalive: options.keepalive }), + method: options.method, + url: requestUrl.toString(), + }); + if (nextFetchResponse) { + const response = nextFetchResponse; + nextFetchResponse = undefined; + return response; + } + let payload: Record; + if (requestUrl.pathname.endsWith('/snapshot')) { + payload = snapshotResponse; + } else if (requestUrl.pathname.endsWith('/status')) { + payload = { + completedRecordingAvailable: false, + ...(completionErrorPending ? { completionError: 'Synthetic retained completion error.' } : {}), + recording: traceRecording, + rendererTracingEnabled: true, + tracingSupported: true, + }; + } else if (requestUrl.pathname.endsWith('/start')) { + traceRecording = true; + payload = { recording: true, rendererTracingEnabled: true, tracingSupported: true }; + } else if (requestUrl.pathname.endsWith('/enable')) { + payload = { rendererTracingEnabled: true }; + } else if (requestUrl.pathname.endsWith('/stop') && completionErrorPending) { + completionErrorPending = false; + return Promise.resolve({ + json: () => Promise.resolve({ error: 'Synthetic retained completion error.' }), + ok: false, + status: 500, + }); + } else if (requestUrl.pathname.endsWith('/stop') && stopFailure) { + const error = stopFailure; + stopFailure = null; + return Promise.resolve({ json: () => Promise.resolve({ error }), ok: false, status: 500 }); + } else { + traceRecording = false; + payload = traceResult(); + } + return Promise.resolve({ json: () => Promise.resolve(payload), ok: true, status: 200 }); + }, + navigator: { clipboard: { writeText: () => Promise.resolve() } }, + window, + }) as DevToolsPerformancePanel; + panel.state.activeSection = 'performance'; + panel.state.target = { id: 'owl:web-preview', sessionId: 'web-preview' }; + }); + + it('binds snapshots and status polling to the complete inspected-target tuple', async () => { + await panel.refreshPerformance(); + + expect(requests.length).toBe(2); + for (const request of requests) { + const url = new URL(request.url); + expect(url.searchParams.get('sessionId')).toBe('web-preview'); + expect(url.searchParams.get('inspectedUrl')).toBe('http://127.0.0.1:54321/index.html?valdiDevTools=1'); + expect(url.searchParams.get('targetNonce')).toBe('panel-target-nonce-123456'); + } + expect(panel.state.performance.samples.length).toBe(1); + expect(panel.performanceContent.innerHTML).toContain('data-performance-scope="valdi"'); + expect(panel.performanceContent.innerHTML).toContain('data-performance-scope="browser"'); + expect(panel.performanceContent.innerHTML).toContain('data-performance-scope="all"'); + expect(panel.performanceContent.innerHTML).not.toContain('data-performance-scope="app"'); + expect(panel.performanceContent.innerHTML).toContain('JavaScript time'); + expect(panel.performanceContent.innerHTML).toContain('Layout time'); + expect(panel.performanceContent.innerHTML).toContain('