diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md index d8d8f02d..7cad1589 100644 --- a/npm_modules/cli/debugger/README.md +++ b/npm_modules/cli/debugger/README.md @@ -98,6 +98,26 @@ serialized `/api/devtools/targets` response is independently capped at 512 KiB. The legacy `/api/status` route intentionally keeps its prior port probing and forwarding behavior and does not run registry discovery. +The DevTools panel has two mutually exclusive identity modes. An extension-opened +inspected page keeps using its exact `sessionId`, `inspectedUrl`, and +`targetNonce` tuple and never requests the target registry. A directly opened +panel receives one opaque `targetId`, discovers at most 256 registry entries, +and never falls back to the first, only, attached, or newly discovered target. +Only attachable `target-id` entries using the `valdi-daemon` transport and +advertising both `components` and `snapshot` can be selected. Inspected-page, +waiting, and unsupported entries remain visible with a disabled explanation. +Direct snapshot and capability-supported tool requests send only the selected +opaque `targetId`. + +Target changes close the old Console stream before installing the replacement, +invalidate outstanding snapshot, highlight, Console, and Performance work, and +clear all target-owned presentation. Console and Performance tabs are enabled +only when the selected descriptor advertises their capability. A target change +is blocked while a Performance operation or recording owns the selected target; +if registry removal forces a detach, the exact previous Performance owner stays +available solely so the recording can be stopped and retrieved. Registry +removal never selects another target automatically. + 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/debugger/devtools-panel.css b/npm_modules/cli/debugger/devtools-panel.css index 749ec4ec..54b15a20 100644 --- a/npm_modules/cli/debugger/devtools-panel.css +++ b/npm_modules/cli/debugger/devtools-panel.css @@ -137,6 +137,13 @@ button { background: var(--surface-hover); } +.main-tab:disabled { + background: transparent; + color: var(--muted); + cursor: not-allowed; + opacity: 0.55; +} + .main-tab.selected, .detail-tab.selected { color: var(--accent); @@ -209,6 +216,38 @@ button { white-space: nowrap; } +.target-select-label { + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + +.target-select { + width: min(320px, 42vw); + min-width: 120px; + height: 22px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--surface); + color: var(--text); + font: inherit; + text-overflow: ellipsis; +} + +.target-select[hidden], +.target-select-label[hidden] { + display: none; +} + +.target-picker-status { + min-width: 0; + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; +} + .target-metadata { overflow: hidden; color: var(--muted); @@ -1023,4 +1062,21 @@ button { .target-metadata { display: none; } + + .target-picker-status { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + border: 0; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } + + .target-select { + width: min(230px, 52vw); + } } diff --git a/npm_modules/cli/debugger/devtools-panel.html b/npm_modules/cli/debugger/devtools-panel.html index bb07b7af..9436bbbc 100644 --- a/npm_modules/cli/debugger/devtools-panel.html +++ b/npm_modules/cli/debugger/devtools-panel.html @@ -18,6 +18,7 @@ role="tab" aria-controls="elementsSection" aria-selected="true" + tabindex="0" > Elements @@ -29,6 +30,7 @@ role="tab" aria-controls="performanceSection" aria-selected="false" + tabindex="-1" > Performance @@ -40,6 +42,7 @@ role="tab" aria-controls="consoleSection" aria-selected="false" + tabindex="-1" > Console @@ -53,9 +56,14 @@
- + + + Connecting to inspected Valdi application… + @@ -76,7 +84,12 @@ -
@@ -91,7 +104,13 @@
Waiting for the inspected Valdi renderer…
- +
@@ -120,6 +139,7 @@ data-panel="performance" role="tabpanel" aria-labelledby="performanceTab" + hidden >
Web preview performance @@ -136,6 +156,7 @@ data-panel="console" role="tabpanel" aria-labelledby="consoleTab" + hidden >
Console output diff --git a/npm_modules/cli/debugger/devtools-panel.js b/npm_modules/cli/debugger/devtools-panel.js index 35d3fca3..83fe2d41 100644 --- a/npm_modules/cli/debugger/devtools-panel.js +++ b/npm_modules/cli/debugger/devtools-panel.js @@ -1,15 +1,68 @@ const query = new URLSearchParams(window.location.search); -const inspectedUrl = query.get('inspectedUrl'); -const inspectedTargetNonce = query.get('targetNonce'); +const MAX_REGISTRY_CAPABILITIES = 32; +const MAX_REGISTRY_ENTRIES = 256; +const MAX_REGISTRY_ID_CHARACTERS = 512; +const MAX_REGISTRY_LABEL_CHARACTERS = 96; +const MAX_REGISTRY_LABEL_TOTAL_CHARACTERS = 180; 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 MANUAL_WEB_CAPABILITIES = new Set(['components', 'console', 'highlight', 'performance', 'snapshot', 'storage']); + +function parseLaunchIdentity(searchParams) { + const targetIds = searchParams.getAll('targetId'); + const inspectedUrls = searchParams.getAll('inspectedUrl'); + const targetNonces = searchParams.getAll('targetNonce'); + if (targetIds.length > 1 || inspectedUrls.length > 1 || targetNonces.length > 1) { + return { error: 'The DevTools URL contains a duplicated target identity.' }; + } + + const targetId = targetIds[0]; + const inspectedPageUrl = inspectedUrls[0]; + const targetNonce = targetNonces[0]; + if (targetId !== undefined) { + if ( + targetId.length === 0 || + targetId.length > MAX_REGISTRY_ID_CHARACTERS || + /[\u0000-\u001f\u007f]/.test(targetId) || + inspectedPageUrl !== undefined || + targetNonce !== undefined + ) { + return { error: 'Direct DevTools requires one bounded targetId and no inspected-page identity.' }; + } + return { mode: 'target-id', requestedTargetId: targetId }; + } + + if ( + inspectedPageUrl === undefined || + inspectedPageUrl.length === 0 || + targetNonce === undefined || + targetNonce.length === 0 + ) { + return { error: 'Inspected-page DevTools requires both inspectedUrl and targetNonce.' }; + } + return { inspectedUrl: inspectedPageUrl, mode: 'inspected-page', targetNonce }; +} + +const launchIdentity = parseLaunchIdentity(query); +const inspectedUrl = launchIdentity.mode === 'inspected-page' ? launchIdentity.inspectedUrl : null; +const inspectedTargetNonce = launchIdentity.mode === 'inspected-page' ? launchIdentity.targetNonce : null; const state = { target: null, + targetGeneration: 0, + targetSwitchMessage: null, + registryTargets: [], + registryError: null, + registryGeneration: 0, + registryRequestGeneration: 0, + registryPending: false, + connectionRequestGeneration: 0, + initialTargetResolutionPending: launchIdentity.mode === 'target-id', + unavailableTargetId: null, snapshot: null, activeSection: 'elements', activeDetail: 'styles', @@ -59,8 +112,11 @@ const elements = { detailTabs: Array.from(document.querySelectorAll('.detail-tab')), sections: Array.from(document.querySelectorAll('.section')), targetStatusDot: document.getElementById('targetStatusDot'), + targetSelectLabel: document.getElementById('targetSelectLabel'), + targetSelect: document.getElementById('targetSelect'), targetName: document.getElementById('targetName'), targetMetadata: document.getElementById('targetMetadata'), + targetPickerStatus: document.getElementById('targetPickerStatus'), autoRefreshToggle: document.getElementById('autoRefreshToggle'), refreshButton: document.getElementById('refreshButton'), treeFilter: document.getElementById('treeFilter'), @@ -119,6 +175,227 @@ async function requestJson(path, params, options) { return payload; } +function isDirectMode() { + return launchIdentity.mode === 'target-id'; +} + +function targetCapabilities(target = state.target) { + if (Array.isArray(target?.capabilities)) return new Set(target.capabilities); + return launchIdentity.mode === 'inspected-page' ? MANUAL_WEB_CAPABILITIES : new Set(); +} + +function targetSupports(capability, target = state.target) { + return Boolean(target && targetCapabilities(target).has(capability)); +} + +function targetIdentityParameters(target = state.target) { + if (!target) throw new Error('No debugger target is selected.'); + if (isDirectMode()) return { targetId: target.id }; + if (!target.sessionId || !inspectedUrl || !inspectedTargetNonce) { + throw new Error('The selected inspected page does not have a complete target identity.'); + } + return { + inspectedUrl, + sessionId: target.sessionId, + targetNonce: inspectedTargetNonce, + }; +} + +function targetIdentityKey(target = state.target) { + const identity = targetIdentityParameters(target); + return Object.keys(identity) + .sort() + .map(key => `${key}:${identity[key]}`) + .join('|'); +} + +function targetOperationalKey(target) { + return `${targetIdentityKey(target)}|capabilities:${JSON.stringify(Array.from(targetCapabilities(target)).sort())}`; +} + +function targetEventMatches(target, payload) { + if (!target || payload.targetId !== target.id) return false; + return isDirectMode() || payload.sessionId === target.sessionId; +} + +function boundedRegistryLabel(value, fallback) { + if (typeof value !== 'string') return fallback; + const normalized = value + .slice(0, MAX_REGISTRY_LABEL_CHARACTERS * 2) + .replace(/[\u0000-\u001f\u007f]+/g, ' ') + .trim(); + if (!normalized) return fallback; + const characters = Array.from(normalized); + return characters.length <= MAX_REGISTRY_LABEL_CHARACTERS + ? normalized + : `${characters.slice(0, MAX_REGISTRY_LABEL_CHARACTERS - 1).join('')}…`; +} + +function parseRegistryTarget(value) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const id = value.id; + if ( + typeof id !== 'string' || + id.length === 0 || + id.length > MAX_REGISTRY_ID_CHARACTERS || + /[\u0000-\u001f\u007f]/.test(id) || + typeof value.attachable !== 'boolean' || + !['inspected-page', 'target-id'].includes(value.identityMode) || + typeof value.transport !== 'string' || + value.transport.length === 0 || + value.transport.length > 64 || + !Array.isArray(value.capabilities) || + value.capabilities.length > MAX_REGISTRY_CAPABILITIES + ) { + return null; + } + + const capabilities = []; + const seenCapabilities = new Set(); + for (const capability of value.capabilities) { + if ( + typeof capability !== 'string' || + capability.length === 0 || + capability.length > 64 || + /[\u0000-\u001f\u007f]/.test(capability) || + seenCapabilities.has(capability) + ) { + return null; + } + seenCapabilities.add(capability); + capabilities.push(capability); + } + + if (value.state !== undefined && !['attached', 'available', 'waiting'].includes(value.state)) return null; + if (value.platform !== undefined && (typeof value.platform !== 'string' || value.platform.length > 32)) return null; + if (value.port !== undefined && (!Number.isSafeInteger(value.port) || value.port <= 0 || value.port > 65_535)) { + return null; + } + const name = boundedRegistryLabel(value.name, ''); + return { + attachable: value.attachable, + capabilities, + id, + identityMode: value.identityMode, + name: name || boundedRegistryLabel(value.applicationId, 'Valdi target'), + platform: boundedRegistryLabel(value.platform, 'unknown'), + ...(value.port === undefined ? {} : { port: value.port }), + state: value.state || 'available', + transport: value.transport, + }; +} + +function parseTargetRegistry(payload) { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload) || !Array.isArray(payload.targets)) { + throw new Error('The debugger target registry returned an invalid response.'); + } + if (payload.targets.length > MAX_REGISTRY_ENTRIES) { + throw new Error(`The debugger target registry exceeded ${MAX_REGISTRY_ENTRIES} entries.`); + } + const targets = []; + const ids = new Set(); + for (const value of payload.targets) { + const target = parseRegistryTarget(value); + if (!target) throw new Error('The debugger target registry contains a malformed entry.'); + if (ids.has(target.id)) throw new Error(`The debugger target registry contains duplicate target IDs.`); + ids.add(target.id); + targets.push(target); + } + return targets; +} + +function isSelectableDirectTarget(target) { + const capabilities = targetCapabilities(target); + return Boolean( + target.attachable && + target.state !== 'waiting' && + target.identityMode === 'target-id' && + target.transport === 'valdi-daemon' && + capabilities.has('components') && + capabilities.has('snapshot'), + ); +} + +function directTargetUnavailableReason(target) { + if (target.identityMode === 'inspected-page') return 'Open from the inspected page'; + if (!target.attachable || target.state === 'waiting') return 'Waiting for application'; + if (target.transport !== 'valdi-daemon') return 'Unsupported transport'; + if (!targetSupports('components', target) || !targetSupports('snapshot', target)) { + return 'Component snapshots unavailable'; + } + return 'Unavailable'; +} + +function directTargetLabel(target) { + const metadata = [target.platform, target.port === undefined ? null : `:${target.port}`].filter(Boolean).join(' '); + const reason = isSelectableDirectTarget(target) ? '' : ` — ${directTargetUnavailableReason(target)}`; + const label = `${target.name}${metadata ? ` (${metadata})` : ''}${reason}`; + const characters = Array.from(label); + return characters.length <= MAX_REGISTRY_LABEL_TOTAL_CHARACTERS + ? label + : `${characters.slice(0, MAX_REGISTRY_LABEL_TOTAL_CHARACTERS - 1).join('')}…`; +} + +function appendTargetOption(value, label, options = {}) { + const option = document.createElement('option'); + option.value = value; + option.textContent = label; + option.disabled = Boolean(options.disabled); + option.selected = Boolean(options.selected); + elements.targetSelect.append(option); +} + +function setTargetPickerStatus(message) { + elements.targetPickerStatus.textContent = message || ''; +} + +function renderTargetPicker() { + const direct = isDirectMode(); + elements.targetSelect.hidden = !direct; + elements.targetSelectLabel.hidden = !direct; + elements.targetName.hidden = direct; + if (!direct) return; + + elements.targetSelect.replaceChildren(); + const selectedId = state.target?.id || null; + const unavailableTarget = state.unavailableTargetId + ? state.registryTargets.find(target => target.id === state.unavailableTargetId) + : null; + appendTargetOption('', state.registryPending ? 'Loading debugger targets…' : 'Choose a debugger target', { + disabled: false, + selected: selectedId === null, + }); + for (const target of state.registryTargets) { + appendTargetOption(target.id, directTargetLabel(target), { + disabled: !isSelectableDirectTarget(target), + selected: target.id === selectedId, + }); + } + if (state.unavailableTargetId && !unavailableTarget) { + appendTargetOption(state.unavailableTargetId, 'Requested target is unavailable', { disabled: true }); + } + elements.targetSelect.value = selectedId || ''; + if (state.targetSwitchMessage) { + setTargetPickerStatus(state.targetSwitchMessage); + } else if (state.registryError) { + setTargetPickerStatus(state.registryError); + } else if (state.error) { + setTargetPickerStatus(state.error); + } else if (state.target) { + setTargetPickerStatus(`Connected to ${state.target.name}.`); + } else if (state.unavailableTargetId) { + setTargetPickerStatus( + unavailableTarget && isSelectableDirectTarget(unavailableTarget) + ? 'The requested target is available. Select it to connect.' + : unavailableTarget + ? directTargetUnavailableReason(unavailableTarget) + : 'The requested target is unavailable. Choose another target.', + ); + } else { + setTargetPickerStatus('Choose a target. Targets are never selected automatically.'); + } +} + function stopPerformanceOnPageHide() { const identity = state.performance.ownerIdentity; if (!identity) return; @@ -164,23 +441,19 @@ function formatUptime(value) { } 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, - }; + if (!targetSupports('performance', target)) throw new Error('The selected target does not support Performance.'); + return targetIdentityParameters(target); } function samePerformanceIdentity(left, right) { - return Boolean( - left && - right && - left.sessionId === right.sessionId && - left.inspectedUrl === right.inspectedUrl && - left.targetNonce === right.targetNonce, + if (!left || !right) return false; + if (left.targetId !== undefined || right.targetId !== undefined) { + return left.targetId !== undefined && left.targetId === right.targetId; + } + return ( + left.sessionId === right.sessionId && + left.inspectedUrl === right.inspectedUrl && + left.targetNonce === right.targetNonce ); } @@ -204,8 +477,11 @@ function preparePerformanceForTargetChange() { perf.pending = false; perf.data = null; perf.lastTrace = null; + perf.navigationExpanded = false; perf.rendererTracingEnabled = false; perf.samples = []; + perf.traceScope = 'valdi'; + perf.traceSearch = ''; if (perf.traceActive || perf.ownerIdentity) { perf.error = 'The previous web preview still owns a performance recording. Stop and retrieve it before switching.'; return; @@ -213,6 +489,122 @@ function preparePerformanceForTargetChange() { perf.error = null; } +function performanceBlocksTargetSwitch() { + const perf = state.performance; + return Boolean(perf.pending || perf.traceActive || perf.ownerIdentity); +} + +function sectionIsAvailable(section) { + if (section === 'elements') return true; + if (section === 'console') return targetSupports('console'); + if (section === 'performance') return targetSupports('performance') || Boolean(state.performance.ownerIdentity); + return false; +} + +function updateCapabilityUi() { + for (const tab of elements.mainTabs) { + const available = sectionIsAvailable(tab.dataset.section); + tab.disabled = !available; + tab.setAttribute('aria-disabled', String(!available)); + } + elements.consoleInput.disabled = !targetSupports('console'); + elements.clearConsoleButton.disabled = !targetSupports('console'); + elements.refreshButton.disabled = !state.target; + if (!sectionIsAvailable(state.activeSection)) setActiveSection('elements'); +} + +function resetHighlightForTargetChange() { + state.highlightIntentGeneration++; + if (state.highlightTimer) window.clearTimeout(state.highlightTimer); + state.highlightTimer = null; + state.hoveredNodeId = null; + state.hoveredSnapshotGeneration = 0; + state.highlightMayBeActive = false; +} + +function enqueueExactHighlightClear(target) { + if (!target || !targetSupports('highlight', target) || !state.highlightMayBeActive) return; + const identity = targetIdentityParameters(target); + const request = state.highlightRequestTail.then(() => + requestJson('/api/devtools/highlight', {}, { body: identity }).catch(error => { + console.warn('Unable to clear the previous Valdi target highlight.', error); + }), + ); + state.highlightRequestTail = request.catch(error => { + console.warn('Unable to order the previous Valdi target highlight clear.', error); + }); +} + +function resetConsoleForTargetChange() { + clearConsole(); + state.consoleHistory = []; + state.consoleHistoryIndex = 0; + elements.consoleInput.value = ''; +} + +function clearTargetPresentation(message) { + state.snapshotRequestGeneration++; + state.refreshPending = false; + state.snapshot = null; + state.snapshotGeneration++; + state.selectedNodeId = null; + state.remoteSelectedNodeId = null; + state.expandedNodeIds.clear(); + resetHighlightForTargetChange(); + resetConsoleForTargetChange(); + preparePerformanceForTargetChange(); + elements.treeEmpty.textContent = message; + render(); +} + +function applyDirectTargetSelection(nextTarget, options = {}) { + const currentId = state.target?.id || null; + const nextId = nextTarget?.id || null; + if (currentId !== null && currentId === nextId && !options.force) { + state.targetSwitchMessage = null; + renderTargetPicker(); + return true; + } + if (!options.force && performanceBlocksTargetSwitch()) { + state.targetSwitchMessage = 'Stop or finish the current Performance operation before switching debugger targets.'; + renderTargetPicker(); + return false; + } + + stopConsoleStream(); + enqueueExactHighlightClear(state.target); + state.targetGeneration++; + clearTargetPresentation(nextTarget ? 'Loading the selected target…' : 'Choose a debugger target to inspect.'); + state.target = nextTarget; + state.unavailableTargetId = options.unavailableTargetId || null; + state.targetSwitchMessage = options.message || null; + state.error = nextTarget + ? null + : options.unavailableTargetId + ? options.message || 'The selected target is unavailable.' + : null; + if (nextTarget) { + elements.targetMetadata.textContent = [ + nextTarget.platform, + nextTarget.port === undefined ? null : `:${nextTarget.port}`, + ] + .filter(Boolean) + .join(' · '); + setConnected(true); + } else { + elements.targetMetadata.textContent = ''; + setConnected(false); + } + updateCapabilityUi(); + if (state.activeSection === 'performance') renderPerformance(); + renderTargetPicker(); + if (nextTarget) { + startConsoleStream(); + void refreshSnapshot(); + } + return true; +} + function nodeAttributes(node) { return valdiDebuggerTreeModel.attributes(node); } @@ -316,7 +708,10 @@ function expandUsefulNodes(root) { function setConnected(connected, message) { elements.targetStatusDot.className = `status-dot${connected ? '' : state.error ? ' error' : ' connecting'}`; - if (message) elements.targetName.textContent = message; + if (message) { + if (isDirectMode()) setTargetPickerStatus(message); + else elements.targetName.textContent = message; + } } function reportError(error) { @@ -326,28 +721,117 @@ function reportError(error) { elements.treeEmpty.textContent = message; } -async function connectToInspectedApplication() { - if (!inspectedUrl || !inspectedTargetNonce) { - state.error = 'The DevTools extension did not provide its inspected page identity.'; - setConnected(false, state.error); - return; +async function refreshTargetRegistry() { + if (!isDirectMode() || state.registryPending) return; + state.registryPending = true; + const requestGeneration = ++state.registryRequestGeneration; + renderTargetPicker(); + try { + const payload = await requestJson('/api/devtools/targets', {}, {}); + if (requestGeneration !== state.registryRequestGeneration) return; + const targets = parseTargetRegistry(payload); + state.registryTargets = targets; + state.registryGeneration++; + state.registryError = null; + if (!state.target) state.error = null; + + if (state.initialTargetResolutionPending) { + state.initialTargetResolutionPending = false; + const requestedId = launchIdentity.requestedTargetId; + const requestedTarget = targets.find(target => target.id === requestedId); + if (requestedTarget && isSelectableDirectTarget(requestedTarget)) { + state.unavailableTargetId = null; + applyDirectTargetSelection(requestedTarget, { force: true }); + } else { + state.unavailableTargetId = requestedId; + state.error = 'The requested target is unavailable.'; + updateCapabilityUi(); + setConnected(false); + } + } else if (state.target) { + const refreshedTarget = targets.find(target => target.id === state.target.id); + if (!refreshedTarget || !isSelectableDirectTarget(refreshedTarget)) { + const unavailableTargetId = state.target.id; + applyDirectTargetSelection(null, { + force: true, + message: 'The selected target is no longer available. Choose another target.', + unavailableTargetId, + }); + } else { + const previousTarget = state.target; + const targetChanged = targetOperationalKey(previousTarget) !== targetOperationalKey(refreshedTarget); + if (targetChanged) { + stopConsoleStream(); + enqueueExactHighlightClear(previousTarget); + state.targetGeneration++; + clearTargetPresentation('Refreshing the selected target…'); + } + state.target = refreshedTarget; + elements.targetMetadata.textContent = [ + refreshedTarget.platform, + refreshedTarget.port === undefined ? null : `:${refreshedTarget.port}`, + ] + .filter(Boolean) + .join(' · '); + updateCapabilityUi(); + if (state.activeSection === 'performance') renderPerformance(); + if (targetChanged) { + startConsoleStream(); + void refreshSnapshot(); + } + } + } + if (!state.target) setConnected(false); + renderTargetPicker(); + } catch (error) { + if (requestGeneration !== state.registryRequestGeneration) return; + state.registryError = error instanceof Error ? error.message : String(error); + if (!state.target) { + state.error = state.registryError; + setConnected(false); + } + renderTargetPicker(); + } finally { + if (requestGeneration === state.registryRequestGeneration) { + state.registryPending = false; + renderTargetPicker(); + } } +} + +async function connectToInspectedPage() { + const connectionGeneration = ++state.connectionRequestGeneration; try { const payload = await requestJson('/api/devtools/target', { inspectedUrl, targetNonce: inspectedTargetNonce }, {}); + if (connectionGeneration !== state.connectionRequestGeneration) return; const previousTargetKey = state.target ? `${state.target.id}:${state.target.sessionId}:${inspectedTargetNonce}` : null; const nextTargetKey = `${payload.target.id}:${payload.target.sessionId}:${inspectedTargetNonce}`; if (previousTargetKey !== null && previousTargetKey !== nextTargetKey) { stopConsoleStream(); - clearConsole(); + enqueueExactHighlightClear(state.target); + resetConsoleForTargetChange(); preparePerformanceForTargetChange(); + state.snapshotRequestGeneration++; + state.refreshPending = false; + state.snapshot = null; + state.snapshotGeneration++; + state.selectedNodeId = null; + state.remoteSelectedNodeId = null; + state.expandedNodeIds.clear(); + resetHighlightForTargetChange(); + render(); } + if (previousTargetKey !== nextTargetKey) state.targetGeneration++; state.target = payload.target; elements.targetName.textContent = state.target.name || 'Valdi application'; elements.targetName.title = state.target.applicationUrl || inspectedUrl; elements.targetMetadata.textContent = `Chromium · :${state.target.debuggingPort}`; + renderTargetPicker(); + updateCapabilityUi(); + if (state.activeSection === 'performance') renderPerformance(); setConnected(true); if (previousTargetKey !== nextTargetKey) { addConsoleEntry('info', `Connected to ${state.target.applicationUrl}`); @@ -356,23 +840,42 @@ async function connectToInspectedApplication() { await refreshSnapshot(); startRefreshTimer(); } catch (error) { + if (connectionGeneration !== state.connectionRequestGeneration) return; reportError(error); - window.setTimeout(connectToInspectedApplication, 1500); + window.setTimeout(connectToInspectedPage, 1500); + } +} + +async function connectToInspectedApplication() { + renderTargetPicker(); + updateCapabilityUi(); + if (launchIdentity.error) { + state.error = launchIdentity.error; + setConnected(false, state.error); + elements.treeEmpty.textContent = state.error; + return; + } + if (isDirectMode()) { + await refreshTargetRegistry(); + startRefreshTimer(); + return; } + await connectToInspectedPage(); } async function refreshSnapshot() { - if (!state.target || state.refreshPending) return; + if (!state.target || !targetSupports('components') || !targetSupports('snapshot') || state.refreshPending) return; state.refreshPending = true; const requestTarget = state.target; + const requestTargetGeneration = state.targetGeneration; const requestGeneration = ++state.snapshotRequestGeneration; + const requestIsCurrent = () => + state.targetGeneration === requestTargetGeneration && + state.target?.id === requestTarget.id && + state.snapshotRequestGeneration === requestGeneration; try { - const snapshot = await requestJson( - '/api/devtools/snapshot', - { inspectedUrl, sessionId: requestTarget.sessionId, targetNonce: inspectedTargetNonce }, - {}, - ); - if (state.target !== requestTarget || state.snapshotRequestGeneration !== requestGeneration) return; + const snapshot = await requestJson('/api/devtools/snapshot', targetIdentityParameters(requestTarget), {}); + if (!requestIsCurrent()) return; snapshot.tree = valdiDebuggerTreeModel.restoreTree(snapshot.tree); const wasEmpty = !state.snapshot?.tree; const shouldClearHighlight = state.hoveredNodeId !== null || state.highlightMayBeActive; @@ -385,6 +888,7 @@ async function refreshSnapshot() { if (shouldClearHighlight) queueHighlight(null); state.error = null; setConnected(true); + renderTargetPicker(); if (wasEmpty) { expandUsefulNodes(snapshot.tree); @@ -402,16 +906,18 @@ async function refreshSnapshot() { } render(); } catch (error) { - reportError(error); + if (requestIsCurrent()) reportError(error); } finally { - state.refreshPending = false; + if (requestIsCurrent()) state.refreshPending = false; } } function startRefreshTimer() { if (state.refreshTimer) window.clearInterval(state.refreshTimer); state.refreshTimer = window.setInterval(() => { - if (!state.autoRefresh || document.hidden) return; + if (document.hidden) return; + if (isDirectMode()) void refreshTargetRegistry(); + if (!state.autoRefresh) return; if (state.activeSection === 'elements') void refreshSnapshot(); if (state.activeSection === 'performance') void refreshPerformance({ silent: true }); }, 1200); @@ -696,12 +1202,19 @@ function handleTreeNavigation(event) { scrollSelectedTreeRowIntoView(); } -function enqueueHighlightRequest(intentGeneration, target, targetSessionId, snapshotGeneration, nodeIdValue) { +function enqueueHighlightRequest( + intentGeneration, + target, + targetGeneration, + snapshotGeneration, + identity, + nodeIdValue, +) { const request = state.highlightRequestTail.then(async () => { if ( intentGeneration !== state.highlightIntentGeneration || - state.target !== target || - target.sessionId !== targetSessionId || + state.targetGeneration !== targetGeneration || + state.target?.id !== target.id || state.snapshotGeneration !== snapshotGeneration ) { return; @@ -712,14 +1225,16 @@ function enqueueHighlightRequest(intentGeneration, target, targetSessionId, snap {}, { body: { - inspectedUrl, - sessionId: targetSessionId, - targetNonce: inspectedTargetNonce, + ...identity, ...(nodeIdValue ? { nodeId: nodeIdValue } : {}), }, }, ); - if (nodeIdValue === null && intentGeneration === state.highlightIntentGeneration) { + if ( + nodeIdValue === null && + intentGeneration === state.highlightIntentGeneration && + state.targetGeneration === targetGeneration + ) { state.highlightMayBeActive = false; } } catch (error) { @@ -734,13 +1249,15 @@ function enqueueHighlightRequest(intentGeneration, target, targetSessionId, snap function queueHighlight(nodeIdValue) { if ( !state.target || + !targetSupports('highlight') || (state.hoveredNodeId === nodeIdValue && state.hoveredSnapshotGeneration === state.snapshotGeneration && !(nodeIdValue === null && state.highlightMayBeActive)) ) return; const target = state.target; - const targetSessionId = target.sessionId; + const targetGeneration = state.targetGeneration; + const identity = targetIdentityParameters(target); const snapshotGeneration = state.snapshotGeneration; const intentGeneration = ++state.highlightIntentGeneration; state.hoveredNodeId = nodeIdValue; @@ -749,9 +1266,14 @@ function queueHighlight(nodeIdValue) { state.highlightTimer = window.setTimeout( () => { state.highlightTimer = null; - if (state.target !== target || state.snapshotGeneration !== snapshotGeneration) return; + if ( + state.targetGeneration !== targetGeneration || + state.target?.id !== target.id || + state.snapshotGeneration !== snapshotGeneration + ) + return; if (nodeIdValue !== null) state.highlightMayBeActive = true; - enqueueHighlightRequest(intentGeneration, target, targetSessionId, snapshotGeneration, nodeIdValue); + enqueueHighlightRequest(intentGeneration, target, targetGeneration, snapshotGeneration, identity, nodeIdValue); }, nodeIdValue ? 80 : 20, ); @@ -1118,11 +1640,22 @@ function downloadPerformanceTrace(result) { async function refreshPerformance(options = {}) { if (options.silent && performancePollingInputIsFocused()) return; - if (!state.target || state.performance.pending || state.performance.snapshotPending) return; + if ( + !state.target || + !targetSupports('performance') || + state.performance.pending || + state.performance.snapshotPending + ) { + return; + } const perf = state.performance; const identity = performanceIdentity(); + const targetGeneration = state.targetGeneration; const requestGeneration = ++perf.requestGeneration; - const requestIsCurrent = () => requestGeneration === perf.requestGeneration && performanceIdentityIsCurrent(identity); + const requestIsCurrent = () => + targetGeneration === state.targetGeneration && + requestGeneration === perf.requestGeneration && + performanceIdentityIsCurrent(identity); perf.snapshotPending = true; if (!options.silent && !perf.data) renderPerformance(); try { @@ -1170,6 +1703,32 @@ async function refreshPerformance(options = {}) { } } +function retainStalePerformanceOwnerAfterCleanupFailure(identity, cleanupError, operationGeneration) { + const perf = state.performance; + if (perf.ownerIdentity) { + const ownerDescription = samePerformanceIdentity(perf.ownerIdentity, identity) + ? 'the same recording is already retained by a newer operation' + : 'a different recording is already owned by a newer operation'; + console.warn(`Unable to retain a stale Performance recording because ${ownerDescription}.`, cleanupError); + return; + } + if (operationGeneration !== perf.operationGeneration && perf.pending) { + console.warn('Unable to retain a stale Performance recording while a newer operation is pending.', cleanupError); + return; + } + + // Exact cleanup failed. Claim a fresh repair generation only when no newer + // operation or owner needs the state, then retain the orphaned exact owner. + const repairGeneration = + operationGeneration === perf.operationGeneration ? operationGeneration : ++perf.operationGeneration; + if (repairGeneration !== perf.operationGeneration) return; + perf.traceActive = true; + perf.ownerIdentity = identity; + perf.error = `The previous web preview still owns a performance recording: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`; + updateCapabilityUi(); + if (state.activeSection === 'performance') renderPerformance(perf.data); +} + async function runPerformanceAction(action) { const perf = state.performance; if (action === 'refresh') { @@ -1180,7 +1739,8 @@ async function runPerformanceAction(action) { if (perf.lastTrace) downloadPerformanceTrace(perf.lastTrace); return; } - if (!state.target || perf.pending) return; + const stoppingPreviousOwner = action === 'trace-stop' && perf.ownerIdentity; + if (perf.pending || (!stoppingPreviousOwner && (!state.target || !targetSupports('performance')))) return; if (['enable-tracing', 'trace-capture', 'trace-start'].includes(action) && (perf.traceActive || perf.ownerIdentity)) { return; } @@ -1191,13 +1751,19 @@ async function runPerformanceAction(action) { return; } - const selectedIdentity = performanceIdentity(); - const identity = action === 'trace-stop' && perf.ownerIdentity ? perf.ownerIdentity : selectedIdentity; + const selectedIdentity = state.target && targetSupports('performance') ? performanceIdentity() : null; + const targetGeneration = state.targetGeneration; + const identity = stoppingPreviousOwner ? perf.ownerIdentity : selectedIdentity; + if (!identity) return; perf.requestGeneration++; perf.snapshotPending = false; const operationGeneration = ++perf.operationGeneration; + const operationIsCurrent = () => operationGeneration === perf.operationGeneration; const selectedTargetIsCurrent = () => - operationGeneration === perf.operationGeneration && performanceIdentityIsCurrent(selectedIdentity); + operationIsCurrent() && + selectedIdentity !== null && + targetGeneration === state.targetGeneration && + performanceIdentityIsCurrent(selectedIdentity); const operationOwnsTrace = () => samePerformanceIdentity(perf.ownerIdentity, identity); let refreshSelectedTarget = false; perf.pending = true; @@ -1226,12 +1792,7 @@ async function runPerformanceAction(action) { 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); - } + retainStalePerformanceOwnerAfterCleanupFailure(identity, cleanupError, operationGeneration); } } return; @@ -1240,6 +1801,7 @@ async function runPerformanceAction(action) { perf.ownerIdentity = perf.traceActive ? identity : null; refreshSelectedTarget = true; } else { + if (!operationIsCurrent()) return; const completedOwnedTrace = ['capture', 'stop'].includes(operation) && operationOwnsTrace(); const completedPreviousOwner = completedOwnedTrace && !samePerformanceIdentity(identity, selectedIdentity); if (completedOwnedTrace) { @@ -1262,16 +1824,17 @@ async function runPerformanceAction(action) { } } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (selectedTargetIsCurrent() || operationOwnsTrace()) { + if (operationIsCurrent() && (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()) { + if (operationIsCurrent()) { perf.pending = false; if (state.activeSection === 'performance') renderPerformance(perf.data); + updateCapabilityUi(); } } if (refreshSelectedTarget && selectedTargetIsCurrent() && state.target) { @@ -1280,18 +1843,42 @@ async function runPerformanceAction(action) { } function setActiveSection(section) { - state.activeSection = section; + const activeSection = sectionIsAvailable(section) ? section : 'elements'; + state.activeSection = activeSection; for (const tab of elements.mainTabs) { - const selected = tab.dataset.section === section; + const selected = tab.dataset.section === activeSection; tab.classList.toggle('selected', selected); tab.setAttribute('aria-selected', String(selected)); + tab.tabIndex = selected ? 0 : -1; } for (const panel of elements.sections) { - panel.classList.toggle('selected', panel.dataset.panel === section); + const selected = panel.dataset.panel === activeSection; + panel.classList.toggle('selected', selected); + panel.hidden = !selected; } - if (section === 'console') elements.consoleInput.focus(); - if (section === 'elements') void refreshSnapshot(); - if (section === 'performance') void refreshPerformance(); + if (activeSection === 'console') elements.consoleInput.focus(); + if (activeSection === 'elements') void refreshSnapshot(); + if (activeSection === 'performance') { + renderPerformance(); + void refreshPerformance(); + } +} + +function handleMainTabNavigation(event) { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + const tabs = elements.mainTabs.filter(tab => !tab.disabled); + if (!tabs.length) return; + event.preventDefault(); + const currentIndex = Math.max(0, tabs.indexOf(event.currentTarget)); + const nextIndex = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? tabs.length - 1 + : (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length; + const nextTab = tabs[nextIndex]; + setActiveSection(nextTab.dataset.section); + nextTab.focus(); } function setActiveDetail(detail) { @@ -1305,31 +1892,32 @@ function setActiveDetail(detail) { } function stopConsoleStream() { - if (!state.consoleStream) return; - state.consoleStream.close(); + if (state.consoleStream) state.consoleStream.close(); state.consoleStream = null; state.consoleStreamTargetKey = null; } function startConsoleStream() { - if (!state.target || !state.autoRefresh || !inspectedUrl || !inspectedTargetNonce) { + if (!state.target || !state.autoRefresh || !targetSupports('console')) { stopConsoleStream(); return; } - const targetKey = `${state.target.id}:${state.target.sessionId}:${inspectedTargetNonce}`; + const target = state.target; + const targetGeneration = state.targetGeneration; + const identity = targetIdentityParameters(target); + const targetKey = `${targetGeneration}:${targetIdentityKey(target)}`; if (state.consoleStream && state.consoleStreamTargetKey === targetKey) return; stopConsoleStream(); const url = new URL('/api/devtools/console/stream', window.location.origin); - url.searchParams.set('inspectedUrl', inspectedUrl); - url.searchParams.set('sessionId', state.target.sessionId); - url.searchParams.set('targetNonce', inspectedTargetNonce); + for (const [key, value] of Object.entries(identity)) url.searchParams.set(key, String(value)); const stream = new EventSource(url.toString()); state.consoleStream = stream; state.consoleStreamTargetKey = targetKey; stream.addEventListener('console', event => { - if (state.consoleStream !== stream || !state.target) return; + if (state.consoleStream !== stream || state.targetGeneration !== targetGeneration || state.target?.id !== target.id) + return; let entry; try { entry = JSON.parse(event.data); @@ -1338,26 +1926,19 @@ function startConsoleStream() { return; } if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return; - if ( - entry.sessionId !== state.target.sessionId || - entry.targetId !== state.target.id || - typeof entry.message !== 'string' - ) { + if (!targetEventMatches(target, entry) || typeof entry.message !== 'string') { return; } addConsoleEntry(entry.level, entry.message, entry.timestamp, entry.source); }); stream.addEventListener('stream-error', event => { - if (state.consoleStream !== stream || !state.target) return; + if (state.consoleStream !== stream || state.targetGeneration !== targetGeneration || state.target?.id !== target.id) + return; try { const payload = JSON.parse(event.data); if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return; - if ( - payload.sessionId === state.target.sessionId && - payload.targetId === state.target.id && - typeof payload.error === 'string' - ) { + if (targetEventMatches(target, payload) && typeof payload.error === 'string') { addConsoleEntry('error', payload.error); } } catch (error) { @@ -1366,15 +1947,12 @@ function startConsoleStream() { }); stream.addEventListener('stream-warning', event => { - if (state.consoleStream !== stream || !state.target) return; + if (state.consoleStream !== stream || state.targetGeneration !== targetGeneration || state.target?.id !== target.id) + return; try { const payload = JSON.parse(event.data); if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return; - if ( - payload.sessionId === state.target.sessionId && - payload.targetId === state.target.id && - typeof payload.message === 'string' - ) { + if (targetEventMatches(target, payload) && typeof payload.message === 'string') { addConsoleEntry('warn', payload.message); } } catch (error) { @@ -1416,6 +1994,11 @@ function clearConsole() { } async function evaluateConsoleExpression(expression) { + if (!state.target || !targetSupports('console')) return; + const target = state.target; + const targetGeneration = state.targetGeneration; + const identity = targetIdentityParameters(target); + const requestIsCurrent = () => state.targetGeneration === targetGeneration && state.target?.id === target.id; addConsoleEntry('input', expression); try { const result = await requestJson( @@ -1424,17 +2007,16 @@ async function evaluateConsoleExpression(expression) { { body: { expression, - inspectedUrl, - sessionId: state.target.sessionId, - targetNonce: inspectedTargetNonce, + ...identity, }, }, ); + if (!requestIsCurrent()) return; const serialized = result.type === 'undefined' ? undefined : JSON.stringify(result.value, null, 2); const value = result.type === 'undefined' ? 'undefined' : (serialized ?? String(result.value)); addConsoleEntry('result', value); } catch (error) { - addConsoleEntry('error', error.message); + if (requestIsCurrent()) addConsoleEntry('error', error instanceof Error ? error.message : String(error)); } } @@ -1456,11 +2038,31 @@ function startSplitResize(event) { function wireEvents() { for (const tab of elements.mainTabs) { tab.addEventListener('click', () => setActiveSection(tab.dataset.section)); + tab.addEventListener('keydown', handleMainTabNavigation); } for (const tab of elements.detailTabs) { tab.addEventListener('click', () => setActiveDetail(tab.dataset.detail)); } + elements.targetSelect.addEventListener('change', () => { + if (!isDirectMode()) return; + const targetId = elements.targetSelect.value; + if (!targetId) { + applyDirectTargetSelection(null); + return; + } + const target = state.registryTargets.find(candidate => candidate.id === targetId); + if (!target || !isSelectableDirectTarget(target)) { + state.targetSwitchMessage = target + ? directTargetUnavailableReason(target) + : 'The requested target is unavailable.'; + renderTargetPicker(); + return; + } + state.unavailableTargetId = null; + applyDirectTargetSelection(target); + }); elements.refreshButton.addEventListener('click', () => { + if (isDirectMode()) void refreshTargetRegistry(); if (state.activeSection === 'performance') void refreshPerformance(); else void refreshSnapshot(); }); @@ -1555,7 +2157,7 @@ function wireEvents() { elements.consoleForm.addEventListener('submit', event => { event.preventDefault(); const expression = elements.consoleInput.value.trim(); - if (!expression || !state.target) return; + if (!expression || !state.target || !targetSupports('console')) return; state.consoleHistory.push(expression); if (state.consoleHistory.length > MAX_CONSOLE_HISTORY_ENTRIES) { state.consoleHistory.splice(0, state.consoleHistory.length - MAX_CONSOLE_HISTORY_ENTRIES); @@ -1587,6 +2189,7 @@ function wireEvents() { stopPerformanceOnPageHide(); }); document.addEventListener('visibilitychange', () => { + if (!document.hidden && isDirectMode()) void refreshTargetRegistry(); 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 6afd197b..de51813e 100644 --- a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts +++ b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts @@ -74,6 +74,110 @@ interface MockConsoleEventSource { emit(type: string, payload: Record): void; } +interface PickerTarget { + attachable: boolean; + capabilities: string[]; + id: string; + identityMode: 'inspected-page' | 'target-id'; + name: string; + platform: string; + port?: number; + state: 'attached' | 'available' | 'waiting'; + transport: string; +} + +interface PickerStubEvent { + currentTarget: PickerStubElement; + key: string; + target: PickerStubElement; + preventDefault(): void; + stopPropagation(): void; +} + +interface PickerStubElement { + checked: boolean; + children: PickerStubElement[]; + classList: { + contains(value: string): boolean; + toggle(value: string, enabled: boolean): void; + }; + className: string; + dataset: Record; + disabled: boolean; + hidden: boolean; + id: string; + innerHTML: string; + open: boolean; + scrollHeight: number; + scrollTop: number; + selected: boolean; + style: Record; + tabIndex: number; + textContent: string; + title: string; + value: string; + addEventListener(type: string, listener: (event: PickerStubEvent) => void): void; + append(child: PickerStubElement): void; + closest(): PickerStubElement | null; + contains(): boolean; + dispatch(type: string, properties?: Partial): void; + focus(): void; + getAttribute(name: string): string | null; + getBoundingClientRect(): { bottom: number; height: number; left: number; right: number; top: number; width: number }; + querySelector(): PickerStubElement | null; + querySelectorAll(): PickerStubElement[]; + removeAttribute(name: string): void; + replaceChildren(): void; + scrollIntoView(): void; + setAttribute(name: string, value: string): void; + setSelectionRange(): void; +} + +interface PickerPanel { + state: { + activeSection: string; + consoleEntries: Array<{ kind: string; value: string }>; + consoleEntryKeys: Set; + consoleHistory: string[]; + error: string | null; + expandedNodeIds: Set; + highlightMayBeActive: boolean; + highlightRequestTail: Promise; + highlightTimer: number | null; + performance: { + data: Record | null; + error: string | null; + lastTrace: Record | null; + ownerIdentity: Record | null; + pending: boolean; + samples: Record[]; + snapshotPending: boolean; + traceActive: boolean; + }; + refreshPending: boolean; + registryPending: boolean; + registryRequestGeneration: number; + registryTargets: PickerTarget[]; + selectedNodeId: string | null; + snapshot: { tree: DevToolsTreeNode } | null; + target: PickerTarget | null; + targetGeneration: number; + targetSwitchMessage: string | null; + unavailableTargetId: string | null; + }; + applyDirectTargetSelection(target: PickerTarget | null, options?: Record): boolean; + connectToInspectedApplication(): Promise; + evaluateConsoleExpression(expression: string): Promise; + parseTargetRegistry(payload: unknown): PickerTarget[]; + queueHighlight(nodeId: string | null): void; + refreshSnapshot(): Promise; + refreshTargetRegistry(): Promise; + renderTargetPicker(): void; + runPerformanceAction(action: string): Promise; + setActiveSection(section: string): void; + startConsoleStream(): void; +} + interface DevToolsConsolePanel { clearButton: StubElement; consoleInput: StubElement; @@ -141,6 +245,321 @@ function componentTree(): DevToolsTreeNode { }; } +function pickerTarget(id: string, overrides: Partial = {}): PickerTarget { + return { + attachable: true, + capabilities: ['components', 'snapshot'], + id, + identityMode: 'target-id', + name: `Target ${id}`, + platform: 'macos', + port: 9166, + state: 'available', + transport: 'valdi-daemon', + ...overrides, + }; +} + +function createPickerStubElement(id: string): PickerStubElement { + const attributes = new Map(); + const classes = new Set(); + const listeners = new Map void>>(); + const element: PickerStubElement = { + checked: true, + children: [], + classList: { + contains: value => classes.has(value), + toggle: (value, enabled) => { + if (enabled) classes.add(value); + else classes.delete(value); + }, + }, + className: '', + dataset: {}, + disabled: false, + hidden: false, + id, + innerHTML: '', + open: false, + scrollHeight: 0, + scrollTop: 0, + selected: false, + style: {}, + tabIndex: -1, + textContent: '', + title: '', + value: '', + addEventListener(type, listener) { + const entries = listeners.get(type) ?? []; + entries.push(listener); + listeners.set(type, entries); + }, + append(child) { + element.children.push(child); + if (child.selected) element.value = child.value; + }, + closest: () => null, + contains: () => true, + dispatch(type, properties = {}) { + const event: PickerStubEvent = { + currentTarget: element, + key: '', + preventDefault() {}, + stopPropagation() {}, + target: element, + ...properties, + }; + for (const listener of listeners.get(type) ?? []) listener(event); + }, + focus() {}, + getAttribute: name => attributes.get(name) ?? null, + getBoundingClientRect: () => ({ bottom: 500, height: 500, left: 0, right: 500, top: 0, width: 500 }), + querySelector: () => null, + querySelectorAll: () => [], + removeAttribute(name) { + attributes.delete(name); + }, + replaceChildren() { + element.children = []; + element.value = ''; + }, + scrollIntoView() {}, + setAttribute(name, value) { + attributes.set(name, value); + }, + setSelectionRange() {}, + }; + return element; +} + +interface PickerFetchRequest { + body?: string; + method: string; + url: string; +} + +interface PickerResponse { + ok: boolean; + status: number; + json(): Promise>; +} + +interface PickerDeferredResponse { + reject(error: Error): void; + resolve(payload: Record, ok?: boolean, status?: number): void; +} + +interface PickerHarness { + closeTargetIds: Array; + elements: Map; + eventSources: MockConsoleEventSource[]; + fetchRequests: PickerFetchRequest[]; + panel: PickerPanel; + queueDeferred(pathname: string): PickerDeferredResponse; + queueResponse(pathname: string, payload: Record, ok?: boolean, status?: number): void; + runTimer(timerId: number): void; +} + +interface PickerPanelReference { + value: PickerPanel | undefined; +} + +function createPickerHarness(search: string): PickerHarness { + 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 fetchRequests: PickerFetchRequest[] = []; + const queuedResponses = new Map>>(); + const eventSources: MockConsoleEventSource[] = []; + const closeTargetIds: Array = []; + const timers = new Map void>(); + const windowListeners = new Map void>(); + const documentDataset: Record = {}; + const activePanel: PickerPanelReference = { value: undefined }; + let nextTimerId = 1; + let activeElement: PickerStubElement | null = null; + + function elementForId(id: string): PickerStubElement { + let element = elements.get(id); + if (!element) { + element = createPickerStubElement(id); + element.focus = () => { + activeElement = element ?? null; + }; + elements.set(id, element); + } + return element; + } + + const mainTabs = ['elements', 'performance', 'console'].map(section => { + const tab = elementForId(`${section}Tab`); + tab.dataset['section'] = section; + return tab; + }); + const sections = ['elements', 'performance', 'console'].map(section => { + const panelElement = elementForId(`${section}Section`); + panelElement.dataset['panel'] = section; + return panelElement; + }); + + const documentObject = { + addEventListener() {}, + get activeElement(): PickerStubElement | null { + return activeElement; + }, + createElement(type: string): PickerStubElement { + return createPickerStubElement(type); + }, + documentElement: { dataset: documentDataset }, + getElementById(id: string): PickerStubElement { + return elementForId(id); + }, + hidden: false, + querySelectorAll(selector: string): PickerStubElement[] { + if (selector === '.main-tab') return mainTabs; + if (selector === '.detail-tab') return []; + if (selector === '.section') return sections; + return []; + }, + }; + + function response(payload: Record, ok = true, status = ok ? 200 : 400): PickerResponse { + return { json: () => Promise.resolve(payload), ok, status }; + } + + function enqueue(pathname: string, plannedResponse: Promise): void { + const entries = queuedResponses.get(pathname) ?? []; + entries.push(plannedResponse); + queuedResponses.set(pathname, entries); + } + + function defaultPayload(pathname: string): Record { + if (pathname === '/api/devtools/targets') return { targets: [] }; + if (pathname === '/api/devtools/snapshot') return { tree: componentTree() }; + if (pathname === '/api/devtools/highlight') return { highlighted: true }; + if (pathname === '/api/devtools/evaluate') return { type: 'string', value: 'ok' }; + if (pathname.endsWith('/trace/stop')) return { traceCount: 0, traces: [] }; + return {}; + } + + class PickerEventSource implements MockConsoleEventSource { + closed = false; + private readonly listeners = new Map void>>(); + + constructor(readonly url: string) { + eventSources.push(this); + } + + addEventListener(type: string, listener: (event: { data: string }) => void): void { + const entries = this.listeners.get(type) ?? []; + entries.push(listener); + this.listeners.set(type, entries); + } + + close(): void { + closeTargetIds.push(activePanel.value?.state.target?.id ?? null); + this.closed = true; + } + + emit(type: string, payload: Record): void { + for (const listener of this.listeners.get(type) ?? []) listener({ data: JSON.stringify(payload) }); + } + } + + const windowObject = { + addEventListener(type: string, listener: () => void) { + windowListeners.set(type, listener); + }, + clearInterval() {}, + clearTimeout(timerId: number) { + timers.delete(timerId); + }, + confirm: () => true, + location: { origin: 'http://127.0.0.1:18768', search }, + parent: {}, + removeEventListener() {}, + setInterval: () => nextTimerId++, + setTimeout(callback: () => void): number { + const timerId = nextTimerId++; + timers.set(timerId, callback); + return timerId; + }, + }; + + const panel = new Script( + `${treeModelSource}\n${panelSource}\n({ applyDirectTargetSelection, connectToInspectedApplication, evaluateConsoleExpression, parseTargetRegistry, queueHighlight, refreshSnapshot, refreshTargetRegistry, renderTargetPicker, runPerformanceAction, setActiveSection, startConsoleStream, state })`, + ).runInNewContext({ + Blob, + EventSource: PickerEventSource, + URL, + URLSearchParams, + console, + document: documentObject, + fetch: (input: URL, options: { body?: string; method: string }) => { + const url = new URL(input.toString()); + fetchRequests.push({ + ...(options.body === undefined ? {} : { body: options.body }), + method: options.method, + url: url.toString(), + }); + const entries = queuedResponses.get(url.pathname); + return entries?.shift() ?? Promise.resolve(response(defaultPayload(url.pathname))); + }, + navigator: { clipboard: { writeText: () => Promise.resolve() } }, + window: windowObject, + }) as PickerPanel; + activePanel.value = panel; + + return { + closeTargetIds, + elements, + eventSources, + fetchRequests, + panel, + queueDeferred(pathname) { + let resolveResponse: ((value: PickerResponse) => void) | undefined; + let rejectResponse: ((error: Error) => void) | undefined; + enqueue( + pathname, + new Promise((resolve, reject) => { + resolveResponse = resolve; + rejectResponse = reject; + }), + ); + return { + reject(error) { + if (!rejectResponse) throw new Error('Deferred response was not initialized.'); + rejectResponse(error); + }, + resolve(payload, ok = true, status = ok ? 200 : 400) { + if (!resolveResponse) throw new Error('Deferred response was not initialized.'); + resolveResponse(response(payload, ok, status)); + }, + }; + }, + queueResponse(pathname, payload, ok = true, status = ok ? 200 : 400) { + enqueue(pathname, Promise.resolve(response(payload, ok, status))); + }, + runTimer(timerId) { + const callback = timers.get(timerId); + if (!callback) throw new Error(`Unknown timer ${timerId}.`); + timers.delete(timerId); + callback(); + }, + }; +} + +async function flushPickerPromises(): Promise { + for (let index = 0; index < 10; index++) await Promise.resolve(); +} + +function requiredPickerElement(harness: PickerHarness, id: string): PickerStubElement { + const element = harness.elements.get(id); + if (!element) throw new Error(`Expected picker element ${id}.`); + return element; +} + describe('integrated DevTools component hierarchy', () => { let fetchRequests: Array<{ body?: string; url: string }>; let fetchResponse: Record; @@ -273,7 +692,9 @@ describe('integrated DevTools component hierarchy', () => { expect(panel.state.expandedNodeIds.has('7')).toBeTrue(); const elementRoot = componentTree().children[0]; - elementRoot.children = [elementRoot.children[0].children[0]]; + const nestedElement = elementRoot?.children[0]?.children[0]; + if (!elementRoot || !nestedElement) throw new Error('Expected the component fixture to contain a nested element.'); + elementRoot.children = [nestedElement]; fetchResponse = { tree: elementRoot }; await panel.refreshSnapshot(); @@ -404,11 +825,676 @@ describe('integrated DevTools component hierarchy', () => { const finalRequestBody = highlightRequests[1]?.body; if (finalRequestBody === undefined) throw new Error('Expected a serialized final clear request.'); const finalRequest = JSON.parse(finalRequestBody) as Record; - expect(finalRequest.nodeId).toBeUndefined(); + expect(finalRequest['nodeId']).toBeUndefined(); expect(panel.state.highlightMayBeActive).toBeFalse(); }); }); +describe('integrated DevTools capability-aware target picker', () => { + it('provides a labeled picker, live status, and accessible tab relationships', () => { + const html = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'devtools-panel.html'), 'utf8'); + const css = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'devtools-panel.css'), 'utf8'); + + expect(html).toContain( + '', + ); + expect(html).toContain('id="targetSelect"'); + expect(html).toMatch(/id="targetPickerStatus"[^>]*role="status"[^>]*aria-live="polite"/); + expect(html).toMatch(/id="targetStatusDot"[^>]*aria-hidden="true"/); + expect(html).toMatch(/id="performanceSection"[\S\s]*?role="tabpanel"[\S\s]*?hidden/); + expect(css).toMatch(/@media \(max-width: 480px\)[\S\s]*?\.target-picker-status\s*{[^}]*clip-path: inset\(50%\)/); + expect(css).not.toMatch(/\.target-picker-status\s*{[^}]*display:\s*none/); + }); + + it('rejects mixed, partial, empty, and duplicated launch identities without making a request', async () => { + const invalidSearches = [ + '?targetId=direct&inspectedUrl=http%3A%2F%2F127.0.0.1%3A1234%2F&targetNonce=nonce', + '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A1234%2F', + '?targetNonce=nonce', + '?targetId=', + '?targetId=one&targetId=two', + '?inspectedUrl=one&inspectedUrl=two&targetNonce=nonce', + ]; + + for (const search of invalidSearches) { + const harness = createPickerHarness(search); + await harness.panel.connectToInspectedApplication(); + expect(harness.fetchRequests).withContext(search).toEqual([]); + expect(harness.panel.state.error) + .withContext(search) + .toMatch(/identity|requires|targetId/); + } + }); + + it('preserves inspected-page mode and never discovers the target registry', async () => { + const harness = createPickerHarness( + '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456', + ); + harness.queueResponse('/api/devtools/target', { + target: { + applicationUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1', + debuggingPort: 9222, + id: 'owl:web-preview', + name: 'index.html', + sessionId: 'web-preview', + }, + }); + harness.queueResponse('/api/devtools/snapshot', { tree: componentTree() }); + + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + + const paths = harness.fetchRequests.map(request => new URL(request.url).pathname); + expect(paths).toContain('/api/devtools/target'); + expect(paths).toContain('/api/devtools/snapshot'); + expect(paths).not.toContain('/api/devtools/targets'); + const snapshotRequest = harness.fetchRequests.find(request => request.url.includes('/snapshot')); + if (!snapshotRequest) throw new Error('Expected an inspected-page snapshot request.'); + const snapshotUrl = new URL(snapshotRequest.url); + expect(Object.fromEntries(snapshotUrl.searchParams)).toEqual({ + inspectedUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1', + sessionId: 'web-preview', + targetNonce: 'panel-target-nonce-123456', + }); + expect(requiredPickerElement(harness, 'targetSelect').hidden).toBeTrue(); + }); + + it('clears target-owned Console state when an inspected-page session is replaced', async () => { + const harness = createPickerHarness( + '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456', + ); + harness.queueResponse('/api/devtools/target', { + target: { + applicationUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1', + debuggingPort: 9222, + id: 'owl:web-preview', + name: 'index.html', + sessionId: 'web-preview', + }, + }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + harness.panel.state.consoleEntries.push({ kind: 'log', value: 'old session output' }); + harness.panel.state.consoleEntryKeys.add('old-session-entry'); + harness.panel.state.consoleHistory.push('oldSessionExpression()'); + requiredPickerElement(harness, 'consoleInput').value = 'old draft'; + harness.queueResponse('/api/devtools/target', { + target: { + applicationUrl: 'http://127.0.0.1:54321/replacement.html?valdiDevTools=1', + debuggingPort: 9222, + id: 'owl:replacement', + name: 'replacement.html', + sessionId: 'replacement', + }, + }); + + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + + expect(harness.panel.state.target?.id).toBe('owl:replacement'); + expect(harness.panel.state.consoleEntries.some(entry => entry.value === 'old session output')).toBeFalse(); + expect(harness.panel.state.consoleEntryKeys.has('old-session-entry')).toBeFalse(); + expect(harness.panel.state.consoleHistory).toEqual([]); + expect(requiredPickerElement(harness, 'consoleInput').value).toBe(''); + }); + + it('rerenders cleared Performance state after an inspected-page replacement with Live off', async () => { + const harness = createPickerHarness( + '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456', + ); + harness.queueResponse('/api/devtools/target', { + target: { + applicationUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1', + debuggingPort: 9222, + id: 'owl:web-preview', + name: 'index.html', + sessionId: 'web-preview', + }, + }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + const liveToggle = requiredPickerElement(harness, 'autoRefreshToggle'); + liveToggle.checked = false; + liveToggle.dispatch('change'); + harness.panel.state.performance.data = { + mainThread: { layoutDurationMs: 2, scriptDurationMs: 4, taskDurationMs: 12 }, + memory: { usedBytes: 2048 }, + resourceCount: 4, + uptimeMs: 100, + }; + harness.panel.state.performance.pending = true; + harness.panel.setActiveSection('performance'); + expect(requiredPickerElement(harness, 'performanceContent').innerHTML).toContain('JS heap'); + harness.queueResponse('/api/devtools/target', { + target: { + applicationUrl: 'http://127.0.0.1:54321/replacement.html?valdiDevTools=1', + debuggingPort: 9222, + id: 'owl:replacement', + name: 'replacement.html', + sessionId: 'replacement', + }, + }); + + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + + expect(harness.panel.state.performance.data).toBeNull(); + expect(harness.panel.state.performance.pending).toBeFalse(); + expect(requiredPickerElement(harness, 'performanceContent').innerHTML).not.toContain('JS heap'); + }); + + it('renders bounded unavailable targets truthfully and never selects the first registry entry', async () => { + const harness = createPickerHarness('?targetId=missing-target'); + const longUnsafeName = `${'x'.repeat(300)}`; + harness.queueResponse('/api/devtools/targets', { + targets: [ + pickerTarget('available-target', { name: longUnsafeName }), + pickerTarget('web-preview', { + identityMode: 'inspected-page', + name: 'Web preview', + transport: 'chromium-cdp', + }), + pickerTarget('waiting-proxy', { + attachable: true, + capabilities: ['components', 'snapshot'], + identityMode: 'target-id', + name: 'Waiting proxy', + state: 'waiting', + transport: 'valdi-daemon', + }), + ], + }); + + await harness.panel.connectToInspectedApplication(); + + expect(harness.panel.state.target).toBeNull(); + expect(harness.fetchRequests.filter(request => request.url.includes('/snapshot'))).toEqual([]); + const options = requiredPickerElement(harness, 'targetSelect').children; + const available = options.find(option => option.value === 'available-target'); + const web = options.find(option => option.value === 'web-preview'); + const waiting = options.find(option => option.value === 'waiting-proxy'); + const missing = options.find(option => option.value === 'missing-target'); + expect(available?.selected).toBeFalse(); + expect(available?.textContent.length).toBeLessThanOrEqual(180); + expect(available?.textContent).toContain(''); + expect(available?.innerHTML).toBe(''); + expect(web?.disabled).toBeTrue(); + expect(web?.textContent).toContain('Open from the inspected page'); + expect(waiting?.disabled).toBeTrue(); + expect(waiting?.textContent).toContain('Waiting for application'); + expect(missing?.disabled).toBeTrue(); + expect(requiredPickerElement(harness, 'targetPickerStatus').textContent).toContain('unavailable'); + }); + + it('fails closed on oversized, duplicated, and malformed registries', () => { + const harness = createPickerHarness('?targetId=requested'); + const oversized = Array.from({ length: 257 }, (_value, index) => pickerTarget(`target-${index}`)); + + for (const [payload, expectedMessage] of [ + [{ targets: oversized }, 'exceeded 256 entries'], + [{ targets: [pickerTarget('duplicate'), pickerTarget('duplicate')] }, 'duplicate target IDs'], + [{ targets: [{ ...pickerTarget('bad-capability'), capabilities: ['snapshot', 'snapshot'] }] }, 'malformed entry'], + [{ targets: [{ ...pickerTarget('bad-port'), port: 0 }] }, 'malformed entry'], + ] as Array<[Record, string]>) { + let message = ''; + try { + harness.panel.parseTargetRegistry(payload); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toContain(expectedMessage); + } + }); + + it('selects only the exact requested opaque ID and sends targetId alone for direct snapshots', async () => { + const harness = createPickerHarness('?targetId=opaque%3Arequested%2Ftarget'); + harness.queueResponse('/api/devtools/targets', { + targets: [pickerTarget('first-target'), pickerTarget('opaque:requested/target')], + }); + harness.queueResponse('/api/devtools/snapshot', { tree: componentTree() }); + + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + + expect(harness.panel.state.target?.id).toBe('opaque:requested/target'); + const snapshotRequest = harness.fetchRequests.find(request => request.url.includes('/api/devtools/snapshot')); + if (!snapshotRequest) throw new Error('Expected a direct snapshot request.'); + const snapshotUrl = new URL(snapshotRequest.url); + expect(Array.from(snapshotUrl.searchParams.keys())).toEqual(['targetId']); + expect(snapshotUrl.searchParams.get('targetId')).toBe('opaque:requested/target'); + expect(harness.fetchRequests.some(request => request.url.includes('/api/devtools/target?'))).toBeFalse(); + }); + + it('clears all target-owned presentation and closes Console before an idle user switch', async () => { + const harness = createPickerHarness('?targetId=target-a'); + const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'console'] }); + const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'console'] }); + harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + + harness.panel.state.consoleEntries.push({ kind: 'log', value: 'old output' }); + harness.panel.state.consoleEntryKeys.add('old'); + harness.panel.state.consoleHistory.push('oldExpression()'); + harness.panel.state.expandedNodeIds.add('7'); + harness.panel.state.selectedNodeId = '7'; + harness.panel.state.performance.data = { uptimeMs: 1 }; + harness.panel.state.performance.lastTrace = { traceCount: 1 }; + harness.panel.state.performance.samples.push({ uptimeMs: 1 }); + const select = requiredPickerElement(harness, 'targetSelect'); + select.value = 'target-b'; + select.dispatch('change'); + + expect(harness.closeTargetIds).toContain('target-a'); + expect(harness.panel.state.target?.id).toBe('target-b'); + expect(harness.panel.state.snapshot).toBeNull(); + expect(harness.panel.state.selectedNodeId).toBeNull(); + expect(harness.panel.state.expandedNodeIds.size).toBe(0); + expect(harness.panel.state.consoleEntries).toEqual([]); + expect(harness.panel.state.consoleEntryKeys.size).toBe(0); + expect(harness.panel.state.consoleHistory).toEqual([]); + expect(harness.panel.state.performance.data).toBeNull(); + expect(harness.panel.state.performance.lastTrace).toBeNull(); + expect(harness.panel.state.performance.samples).toEqual([]); + }); + + it('blocks pending and owned Performance switches and restores the selected option', async () => { + const harness = createPickerHarness('?targetId=target-a'); + const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'performance'] }); + const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'performance'] }); + harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + const select = requiredPickerElement(harness, 'targetSelect'); + + harness.panel.state.performance.pending = true; + select.value = 'target-b'; + select.dispatch('change'); + expect(harness.panel.state.target?.id).toBe('target-a'); + expect(select.value).toBe('target-a'); + expect(requiredPickerElement(harness, 'targetPickerStatus').textContent).toContain('before switching'); + + harness.panel.state.performance.pending = false; + harness.panel.state.performance.traceActive = true; + harness.panel.state.performance.ownerIdentity = { targetId: 'target-a' }; + select.value = 'target-b'; + select.dispatch('change'); + expect(harness.panel.state.target?.id).toBe('target-a'); + expect(select.value).toBe('target-a'); + }); + + it('detaches on registry removal without fallback and keeps exact-owner Stop reachable', async () => { + const harness = createPickerHarness('?targetId=target-a'); + const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'performance'] }); + const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'performance'] }); + harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + harness.panel.setActiveSection('performance'); + harness.panel.state.performance.traceActive = true; + harness.panel.state.performance.ownerIdentity = { targetId: 'target-a' }; + harness.queueResponse('/api/devtools/targets', { targets: [targetB] }); + + await harness.panel.refreshTargetRegistry(); + + expect(harness.panel.state.target).toBeNull(); + expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-a' }); + expect(requiredPickerElement(harness, 'performanceTab').disabled).toBeFalse(); + expect(harness.panel.state.activeSection).toBe('performance'); + harness.queueResponse('/api/devtools/performance/trace/stop', { traceCount: 0, traces: [] }); + await harness.panel.runPerformanceAction('trace-stop'); + const stopRequest = harness.fetchRequests.find(request => request.url.includes('/trace/stop')); + if (!stopRequest) throw new Error('Expected an exact-owner Performance stop.'); + expect(new URL(stopRequest.url).searchParams.get('targetId')).toBe('target-a'); + expect(harness.panel.state.performance.ownerIdentity).toBeNull(); + expect(requiredPickerElement(harness, 'performanceTab').disabled).toBeTrue(); + expect(harness.panel.state.activeSection).toBe('elements'); + }); + + it('renders exact-owner recovery without stale metrics when removal interrupts a capture offscreen', async () => { + const harness = createPickerHarness('?targetId=target-a'); + const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'performance'] }); + const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'performance'] }); + harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + harness.panel.state.performance.data = { + mainThread: { layoutDurationMs: 2, scriptDurationMs: 4, taskDurationMs: 12 }, + memory: { usedBytes: 2048 }, + resourceCount: 4, + uptimeMs: 100, + }; + const oldCapture = harness.queueDeferred('/api/devtools/performance/trace/capture'); + const oldCaptureAction = harness.panel.runPerformanceAction('trace-capture'); + await flushPickerPromises(); + expect(harness.panel.state.activeSection).toBe('elements'); + expect(harness.panel.state.performance.pending).toBeTrue(); + expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-a' }); + expect(requiredPickerElement(harness, 'performanceContent').innerHTML).toContain('JS heap'); + harness.queueResponse('/api/devtools/targets', { targets: [targetB] }); + + await harness.panel.refreshTargetRegistry(); + harness.panel.setActiveSection('performance'); + + const recoveryContent = requiredPickerElement(harness, 'performanceContent').innerHTML; + expect(harness.panel.state.target).toBeNull(); + expect(harness.panel.state.performance.data).toBeNull(); + expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-a' }); + expect(recoveryContent).toContain('Stop and retrieve'); + expect(recoveryContent).toContain('data-performance-action="trace-stop"'); + expect(recoveryContent).not.toContain('data-performance-action="trace-stop" disabled'); + expect(recoveryContent).not.toContain('JS heap'); + + oldCapture.resolve({ traceCount: 1, traces: [] }); + await oldCaptureAction; + expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-a' }); + expect(harness.panel.state.performance.pending).toBeFalse(); + }); + + it('does not let an old owner Stop result or finally clear a newer recovery Stop', async () => { + const harness = createPickerHarness('?targetId=target-a'); + const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'performance'] }); + const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'performance'] }); + harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + harness.panel.state.performance.traceActive = true; + harness.panel.state.performance.ownerIdentity = { targetId: 'target-a' }; + const oldStop = harness.queueDeferred('/api/devtools/performance/trace/stop'); + const oldStopAction = harness.panel.runPerformanceAction('trace-stop'); + await flushPickerPromises(); + harness.queueResponse('/api/devtools/targets', { targets: [targetB] }); + await harness.panel.refreshTargetRegistry(); + const recoveryStop = harness.queueDeferred('/api/devtools/performance/trace/stop'); + const recoveryStopAction = harness.panel.runPerformanceAction('trace-stop'); + await flushPickerPromises(); + harness.panel.state.performance.lastTrace = { marker: 'newer recovery state' }; + + oldStop.resolve({ marker: 'old stop result', traceCount: 1, traces: [] }); + await oldStopAction; + + expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-a' }); + expect(harness.panel.state.performance.traceActive).toBeTrue(); + expect(harness.panel.state.performance.pending).toBeTrue(); + expect(harness.panel.state.performance.lastTrace).toEqual({ marker: 'newer recovery state' }); + expect(harness.panel.state.performance.error).toBeNull(); + + recoveryStop.resolve({ traceCount: 2, traces: [] }); + await recoveryStopAction; + expect(harness.panel.state.performance.ownerIdentity).toBeNull(); + expect(harness.panel.state.performance.pending).toBeFalse(); + }); + + it('does not let an old capture error or finally overwrite a new target capture', async () => { + const harness = createPickerHarness('?targetId=target-a'); + const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'performance'] }); + const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'performance'] }); + harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + const oldCapture = harness.queueDeferred('/api/devtools/performance/trace/capture'); + const oldCaptureAction = harness.panel.runPerformanceAction('trace-capture'); + await flushPickerPromises(); + harness.queueResponse('/api/devtools/targets', { targets: [targetB] }); + await harness.panel.refreshTargetRegistry(); + harness.queueResponse('/api/devtools/performance/trace/stop', { traceCount: 0, traces: [] }); + await harness.panel.runPerformanceAction('trace-stop'); + const select = requiredPickerElement(harness, 'targetSelect'); + select.value = 'target-b'; + select.dispatch('change'); + const newCapture = harness.queueDeferred('/api/devtools/performance/trace/capture'); + const newCaptureAction = harness.panel.runPerformanceAction('trace-capture'); + await flushPickerPromises(); + harness.panel.state.performance.data = { marker: 'new target metrics' }; + harness.panel.state.performance.lastTrace = { marker: 'new target trace' }; + const warn = spyOn(console, 'warn'); + + oldCapture.reject(new Error('old capture failure')); + await oldCaptureAction; + + expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-b' }); + expect(harness.panel.state.performance.traceActive).toBeTrue(); + expect(harness.panel.state.performance.pending).toBeTrue(); + expect(harness.panel.state.performance.error).toBeNull(); + expect(harness.panel.state.performance.data).toEqual({ marker: 'new target metrics' }); + expect(harness.panel.state.performance.lastTrace).toEqual({ marker: 'new target trace' }); + expect(warn).toHaveBeenCalledWith('Ignoring a stale web preview performance action error.', jasmine.anything()); + + newCapture.resolve({ traceCount: 3, traces: [] }); + await newCaptureAction; + expect(harness.panel.state.performance.ownerIdentity).toBeNull(); + expect(harness.panel.state.performance.pending).toBeFalse(); + }); + + it('keeps a replacement snapshot pending when an older target resolves or rejects', async () => { + const harness = createPickerHarness('?targetId=target-a'); + const targetA = pickerTarget('target-a'); + const targetB = pickerTarget('target-b'); + harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + + const oldSnapshot = harness.queueDeferred('/api/devtools/snapshot'); + void harness.panel.refreshSnapshot(); + const replacementSnapshot = harness.queueDeferred('/api/devtools/snapshot'); + const select = requiredPickerElement(harness, 'targetSelect'); + select.value = 'target-b'; + select.dispatch('change'); + oldSnapshot.resolve({ tree: componentTree() }); + await flushPickerPromises(); + expect(harness.panel.state.target?.id).toBe('target-b'); + expect(harness.panel.state.snapshot).toBeNull(); + expect(harness.panel.state.refreshPending).toBeTrue(); + replacementSnapshot.resolve({ tree: componentTree() }); + await flushPickerPromises(); + expect(harness.panel.state.refreshPending).toBeFalse(); + + const staleError = harness.queueDeferred('/api/devtools/snapshot'); + void harness.panel.refreshSnapshot(); + const finalSnapshot = harness.queueDeferred('/api/devtools/snapshot'); + select.value = 'target-a'; + select.dispatch('change'); + staleError.reject(new Error('stale target failure')); + await flushPickerPromises(); + expect(harness.panel.state.error).toBeNull(); + expect(harness.panel.state.refreshPending).toBeTrue(); + finalSnapshot.resolve({ tree: componentTree() }); + await flushPickerPromises(); + }); + + it('orders an exact old-target highlight clear before new-target highlight work', async () => { + const harness = createPickerHarness('?targetId=target-a'); + const capabilities = ['components', 'snapshot', 'highlight']; + const targetA = pickerTarget('target-a', { capabilities }); + const targetB = pickerTarget('target-b', { capabilities }); + harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + const oldHighlight = harness.queueDeferred('/api/devtools/highlight'); + + harness.panel.queueHighlight('7'); + if (harness.panel.state.highlightTimer === null) throw new Error('Expected a highlight timer.'); + harness.runTimer(harness.panel.state.highlightTimer); + await flushPickerPromises(); + const select = requiredPickerElement(harness, 'targetSelect'); + select.value = 'target-b'; + select.dispatch('change'); + expect(harness.fetchRequests.filter(request => request.url.includes('/highlight')).length).toBe(1); + + oldHighlight.resolve({ highlighted: true }); + await harness.panel.state.highlightRequestTail; + const highlightRequests = harness.fetchRequests.filter(request => request.url.includes('/highlight')); + expect(highlightRequests.length).toBe(2); + const finalHighlightRequest = highlightRequests[1]; + if (finalHighlightRequest?.body === undefined) throw new Error('Expected a serialized old-target highlight clear.'); + expect(JSON.parse(finalHighlightRequest.body)).toEqual({ targetId: 'target-a' }); + }); + + it('drops old Console streams and evaluation success or failure after a switch', async () => { + const harness = createPickerHarness('?targetId=target-a'); + const capabilities = ['components', 'snapshot', 'console']; + const targetA = pickerTarget('target-a', { capabilities }); + const targetB = pickerTarget('target-b', { capabilities }); + harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + const oldStream = harness.eventSources[0]; + if (!oldStream) throw new Error('Expected a Console stream.'); + const oldEvaluation = harness.queueDeferred('/api/devtools/evaluate'); + void harness.panel.evaluateConsoleExpression('oldValue()'); + const select = requiredPickerElement(harness, 'targetSelect'); + select.value = 'target-b'; + select.dispatch('change'); + + oldStream.emit('console', { level: 'log', message: 'stale log', targetId: 'target-a' }); + oldStream.emit('stream-warning', { message: 'stale warning', targetId: 'target-a' }); + oldStream.emit('stream-error', { error: 'stale error', targetId: 'target-a' }); + oldEvaluation.resolve({ type: 'string', value: 'stale result' }); + await flushPickerPromises(); + expect(harness.panel.state.consoleEntries).toEqual([]); + + const staleFailure = harness.queueDeferred('/api/devtools/evaluate'); + void harness.panel.evaluateConsoleExpression('secondOldValue()'); + select.value = 'target-a'; + select.dispatch('change'); + staleFailure.reject(new Error('stale evaluation failure')); + await flushPickerPromises(); + expect(harness.panel.state.consoleEntries).toEqual([]); + }); + + it('gates unsupported tabs and generates no Console or Performance traffic for component-only targets', async () => { + const harness = createPickerHarness('?targetId=components-only'); + harness.queueResponse('/api/devtools/targets', { targets: [pickerTarget('components-only')] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + + expect(requiredPickerElement(harness, 'consoleTab').disabled).toBeTrue(); + expect(requiredPickerElement(harness, 'consoleTab').getAttribute('aria-disabled')).toBe('true'); + expect(requiredPickerElement(harness, 'performanceTab').disabled).toBeTrue(); + expect(harness.eventSources).toEqual([]); + harness.panel.setActiveSection('console'); + expect(harness.panel.state.activeSection).toBe('elements'); + harness.panel.setActiveSection('performance'); + expect(harness.panel.state.activeSection).toBe('elements'); + await harness.panel.evaluateConsoleExpression('shouldNotRun()'); + expect( + harness.fetchRequests.some(request => + ['/api/devtools/evaluate', '/api/devtools/console/stream', '/api/devtools/performance'].some(pathname => + request.url.includes(pathname), + ), + ), + ).toBeFalse(); + }); + + it('uses roving tab focus and skips capability-disabled tools during keyboard navigation', async () => { + const harness = createPickerHarness('?targetId=console-target'); + harness.queueResponse('/api/devtools/targets', { + targets: [pickerTarget('console-target', { capabilities: ['components', 'snapshot', 'console'] })], + }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + harness.panel.setActiveSection('elements'); + const elementsTab = requiredPickerElement(harness, 'elementsTab'); + const performanceTab = requiredPickerElement(harness, 'performanceTab'); + const consoleTab = requiredPickerElement(harness, 'consoleTab'); + + elementsTab.dispatch('keydown', { key: 'ArrowRight' }); + + expect(performanceTab.disabled).toBeTrue(); + expect(harness.panel.state.activeSection).toBe('console'); + expect(elementsTab.tabIndex).toBe(-1); + expect(consoleTab.tabIndex).toBe(0); + expect(consoleTab.getAttribute('aria-selected')).toBe('true'); + expect(requiredPickerElement(harness, 'elementsSection').hidden).toBeTrue(); + expect(requiredPickerElement(harness, 'consoleSection').hidden).toBeFalse(); + + consoleTab.dispatch('keydown', { key: 'ArrowRight' }); + expect(harness.panel.state.activeSection).toBe('elements'); + }); + + it('invalidates streams and active tabs when capabilities change for the same target ID', async () => { + const harness = createPickerHarness('?targetId=target-a'); + const consoleTarget = pickerTarget('target-a', { + capabilities: ['components', 'snapshot', 'console', 'performance'], + }); + harness.queueResponse('/api/devtools/targets', { targets: [consoleTarget] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + const oldGeneration = harness.panel.state.targetGeneration; + const oldStream = harness.eventSources[0]; + if (!oldStream) throw new Error('Expected a Console stream.'); + harness.panel.state.consoleEntries.push({ kind: 'log', value: 'old capability output' }); + harness.panel.state.consoleEntryKeys.add('old-capability-entry'); + harness.panel.state.consoleHistory.push('oldCapabilityExpression()'); + harness.panel.state.expandedNodeIds.add('7'); + harness.panel.state.selectedNodeId = '7'; + harness.panel.state.performance.data = { uptimeMs: 1 }; + harness.panel.state.performance.lastTrace = { traceCount: 1 }; + harness.panel.state.performance.samples.push({ uptimeMs: 1 }); + harness.panel.setActiveSection('console'); + const replacementSnapshot = harness.queueDeferred('/api/devtools/snapshot'); + harness.queueResponse('/api/devtools/targets', { + targets: [pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'console,performance'] })], + }); + + await harness.panel.refreshTargetRegistry(); + + expect(harness.panel.state.target?.id).toBe('target-a'); + expect(harness.panel.state.targetGeneration).toBeGreaterThan(oldGeneration); + expect(oldStream.closed).toBeTrue(); + expect(harness.panel.state.selectedNodeId).toBeNull(); + expect(harness.panel.state.expandedNodeIds.size).toBe(0); + expect(harness.panel.state.consoleEntries).toEqual([]); + expect(harness.panel.state.consoleEntryKeys.size).toBe(0); + expect(harness.panel.state.consoleHistory).toEqual([]); + expect(harness.panel.state.performance.data).toBeNull(); + expect(harness.panel.state.performance.lastTrace).toBeNull(); + expect(harness.panel.state.performance.samples).toEqual([]); + expect(requiredPickerElement(harness, 'consoleTab').disabled).toBeTrue(); + expect(harness.panel.state.activeSection).toBe('elements'); + oldStream.emit('console', { level: 'log', message: 'stale same-id log', targetId: 'target-a' }); + expect(harness.panel.state.consoleEntries).toEqual([]); + replacementSnapshot.resolve({ tree: componentTree() }); + await flushPickerPromises(); + }); + + it('keeps the exact current target when a later registry payload is malformed', async () => { + const harness = createPickerHarness('?targetId=target-a'); + harness.queueResponse('/api/devtools/targets', { targets: [pickerTarget('target-a'), pickerTarget('target-b')] }); + await harness.panel.connectToInspectedApplication(); + await flushPickerPromises(); + const targetGeneration = harness.panel.state.targetGeneration; + harness.queueResponse('/api/devtools/targets', { + targets: [pickerTarget('duplicate'), pickerTarget('duplicate')], + }); + + await harness.panel.refreshTargetRegistry(); + + expect(harness.panel.state.target?.id).toBe('target-a'); + expect(harness.panel.state.targetGeneration).toBe(targetGeneration); + expect(requiredPickerElement(harness, 'targetSelect').value).toBe('target-a'); + expect(requiredPickerElement(harness, 'targetPickerStatus').textContent).toContain('duplicate target IDs'); + }); + + it('lets only the newest registry generation publish and still does not auto-select', async () => { + const harness = createPickerHarness('?targetId=requested-target'); + const first = harness.queueDeferred('/api/devtools/targets'); + void harness.panel.refreshTargetRegistry(); + harness.panel.state.registryPending = false; + const second = harness.queueDeferred('/api/devtools/targets'); + void harness.panel.refreshTargetRegistry(); + + second.resolve({ targets: [pickerTarget('newest-target')] }); + await flushPickerPromises(); + first.resolve({ targets: [pickerTarget('requested-target')] }); + await flushPickerPromises(); + + expect(harness.panel.state.registryTargets.map(target => target.id)).toEqual(['newest-target']); + expect(harness.panel.state.target).toBeNull(); + expect(harness.panel.state.unavailableTargetId).toBe('requested-target'); + }); +}); + describe('integrated DevTools console panel', () => { let eventSources: MockConsoleEventSource[]; let panel: DevToolsConsolePanel; @@ -977,8 +2063,7 @@ describe('integrated DevTools performance panel', () => { it('invalidates an old snapshot without wedging polling when the target changes', async () => { let resolveSnapshot: - | ((response: { ok: boolean; status: number; json(): Promise> }) => void) - | undefined; + ((response: { ok: boolean; status: number; json(): Promise> }) => void) | undefined; nextFetchResponse = new Promise(resolve => { resolveSnapshot = resolve; }); @@ -1002,8 +2087,7 @@ describe('integrated DevTools performance panel', () => { it('does not let a delayed pre-start status response overwrite a successful Start', async () => { panel.state.performance.data = snapshot; let resolveSnapshot: - | ((response: { ok: boolean; status: number; json(): Promise> }) => void) - | undefined; + ((response: { ok: boolean; status: number; json(): Promise> }) => void) | undefined; nextFetchResponse = new Promise(resolve => { resolveSnapshot = resolve; }); @@ -1022,8 +2106,7 @@ describe('integrated DevTools performance panel', () => { it('cleans up a stale successful start without overwriting the replacement target', async () => { panel.state.performance.data = snapshot; let resolveStart: - | ((response: { ok: boolean; status: number; json(): Promise> }) => void) - | undefined; + ((response: { ok: boolean; status: number; json(): Promise> }) => void) | undefined; nextFetchResponse = new Promise(resolve => { resolveStart = resolve; }); @@ -1054,8 +2137,7 @@ describe('integrated DevTools performance panel', () => { it('retains and surfaces a stale Start owner when exact cleanup fails', async () => { panel.state.performance.data = snapshot; let resolveStart: - | ((response: { ok: boolean; status: number; json(): Promise> }) => void) - | undefined; + ((response: { ok: boolean; status: number; json(): Promise> }) => void) | undefined; nextFetchResponse = new Promise(resolve => { resolveStart = resolve; }); @@ -1085,11 +2167,49 @@ describe('integrated DevTools performance panel', () => { expect(panel.state.performance.ownerIdentity).toBeNull(); }); - it('keeps an in-flight Capture owned across a target change without publishing its old result', async () => { + it('does not replace a newer owner when stale Start cleanup fails', async () => { + panel.state.performance.data = snapshot; + let resolveStart: + ((response: { ok: boolean; status: number; json(): Promise> }) => void) | undefined; + nextFetchResponse = new Promise(resolve => { + resolveStart = resolve; + }); + const staleStart = panel.runPerformanceAction('trace-start'); + await Promise.resolve(); + + panel.preparePerformanceForTargetChange(); + panel.state.target = { id: 'owl:replacement', sessionId: 'replacement' }; + await panel.runPerformanceAction('trace-start'); + expect(panel.state.performance.ownerIdentity).toEqual(jasmine.objectContaining({ sessionId: 'replacement' })); + stopFailure = 'Synthetic stale cleanup failure.'; + const warn = spyOn(console, 'warn'); + if (!resolveStart) throw new Error('Expected a deferred stale performance start.'); + resolveStart({ + json: () => Promise.resolve({ recording: true, rendererTracingEnabled: true, tracingSupported: true }), + ok: true, + status: 200, + }); + await staleStart; + + const cleanupRequest = requests.find( + request => + new URL(request.url).pathname.endsWith('/trace/stop') && + new URL(request.url).searchParams.get('sessionId') === 'web-preview', + ); + if (!cleanupRequest) throw new Error('Expected exact stale-owner cleanup.'); + expect(panel.state.performance.ownerIdentity).toEqual(jasmine.objectContaining({ sessionId: 'replacement' })); + expect(panel.state.performance.traceActive).toBeTrue(); + expect(panel.state.performance.error).toBeNull(); + expect(warn).toHaveBeenCalledWith( + 'Unable to retain a stale Performance recording because a different recording is already owned by a newer operation.', + jasmine.anything(), + ); + }); + + it('keeps an in-flight Capture owned for exact recovery after a target change', async () => { panel.state.performance.data = snapshot; let resolveCapture: - | ((response: { ok: boolean; status: number; json(): Promise> }) => void) - | undefined; + ((response: { ok: boolean; status: number; json(): Promise> }) => void) | undefined; nextFetchResponse = new Promise(resolve => { resolveCapture = resolve; }); @@ -1106,9 +2226,14 @@ describe('integrated DevTools performance panel', () => { resolveCapture({ json: () => Promise.resolve(traceResult()), ok: true, status: 200 }); await capture; + expect(panel.state.performance.traceActive).toBeTrue(); + expect(panel.state.performance.ownerIdentity).toEqual(jasmine.objectContaining({ sessionId: 'web-preview' })); + expect(panel.state.performance.lastTrace).toBeNull(); + expect(panel.state.performance.pending).toBeFalse(); + + await panel.runPerformanceAction('trace-stop'); expect(panel.state.performance.traceActive).toBeFalse(); expect(panel.state.performance.ownerIdentity).toBeNull(); - expect(panel.state.performance.lastTrace).toBeNull(); }); it('skips silent polling while a generated Performance input is focused', async () => {