From 8334cd6358d4a35fe733b7cd4e49bb2eb172aa8a Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Wed, 26 Aug 2026 09:12:18 -0700 Subject: [PATCH] feat(debugger): add bounded runtime state inspection --- npm_modules/cli/debugger/README.md | 41 +- npm_modules/cli/debugger/devtools-panel.css | 183 ++++ npm_modules/cli/debugger/devtools-panel.html | 88 +- npm_modules/cli/debugger/devtools-panel.js | 780 ++++++++++++++- .../cli/src/debugger/devtoolsPanel.spec.ts | 101 +- .../src/debugger/devtoolsRuntimeState.spec.ts | 916 ++++++++++++++++++ .../src/ValdiWebRendererDelegate.ts | 36 +- .../src/debug/ComponentHierarchySnapshot.ts | 240 ++++- .../src/debug/DebuggerValueSnapshot.ts | 116 ++- .../test/DebuggerValueSnapshot.spec.ts | 110 +++ .../test/LegacyWebDebuggerAdapter.spec.ts | 470 ++++++++- 11 files changed, 2976 insertions(+), 105 deletions(-) create mode 100644 npm_modules/cli/src/debugger/devtoolsRuntimeState.spec.ts diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md index 7e16367b..55c1670c 100644 --- a/npm_modules/cli/debugger/README.md +++ b/npm_modules/cli/debugger/README.md @@ -21,7 +21,7 @@ the CLI package. - `debugger-actions.js`: UI actions, command prompt handling, auto-refresh, and externally driven debugger actions. - `debugger-session.js`: `sessionStorage` restore/persist for reload-friendly debugger state. - `debugger-bootstrap.js`: DOM event wiring and boot sequence. -- `devtools-panel.html`, `devtools-panel.css`, and `devtools-panel.js`: the focused Chromium Elements, Console, and bounded Performance panel embedded by the generated extension. +- `devtools-panel.html`, `devtools-panel.css`, and `devtools-panel.js`: the focused Chromium Elements, State, Console, and bounded Performance panel embedded by the generated extension. Scripts are loaded as classic browser scripts in the order listed in `index.html`. There is no module loader or bundler for this frontend; shared @@ -61,6 +61,45 @@ revoked Proxy therefore causes that component's properties to be omitted while preserving the hierarchy. Prototype traversal is not used, and property values are never read through normal property access. +The same web hierarchy snapshot may include read-only component runtime state +without adding a route, capability, or renderer API. This is taken from the +exact component instance, not its ViewModel, and only from an own, enumerable +data descriptor named `state` whose value is a non-null object. Getters, +inherited fields, scalar state, and throwing reflection are omitted. The state +object is detached under the existing descriptor-only snapshot rules, encoded +as JSON, and admitted only when the exact escaped `component.state` wire string +fits the component snapshot's remaining 64 KiB UTF-8 budget after properties. +Detached and escaped-wire projection work is also bounded across the complete +hierarchy by separate byte, reflection, and attempt counters; indeterminate +nested reflection exhausts that State-only work budget without affecting +component properties or hierarchy capture. Own-key enumeration itself is not +resumable, so a Proxy may still materialize one complete key list. State checks +that list against its remaining reflection allowance before performing any +per-name descriptor lookup, and checks the allowance again before every nested +object or array descriptor lookup. +The pre-existing property-edit metadata is not debited from that counter and +remains bounded by the complete snapshot envelope. Topology revalidation binds +the same virtual node and component instance; an ineligible descriptor or +changed raw state identity removes only `component.state`, while ViewModel +replacement continues to remove only properties and edit metadata. If the +final hierarchy envelope is too large, runtime state is stripped and retried +before existing component properties and edit metadata are stripped. + +Native detailed snapshots retain their existing human-readable +`IRenderedVirtualNodeData.state` string and are not changed by this web capture. +The State panel determines the serialization format from the target identity. +Inspected Chromium web snapshots accept strict JSON only. Native debug strings +do not escape keys, quoted contents, function names, error text, or marker text, +so every direct native snapshot is displayed as escaped raw text rather than +interpreted structurally. Raw display remains searchable. Input is capped at +65,536 characters; parsed web input is additionally capped at depth 12, tokens 2,000, +entries per container at 100, and keys at 1,024 characters. Parsed records have +null prototypes and are populated with data descriptors. Rendering is further +capped at 500 stateful components and 1,000 value rows. Main-panel search and +disclosures are independent from selected-component State details, and every +Inspect action is bound to the exact current snapshot node and render +generation. + An accepted web snapshot may additionally promote the separate `component-property-edit` capability when both the renderer's dedicated full-ViewModel mutation API and secure browser randomness are available. Static diff --git a/npm_modules/cli/debugger/devtools-panel.css b/npm_modules/cli/debugger/devtools-panel.css index 06267691..59fe6575 100644 --- a/npm_modules/cli/debugger/devtools-panel.css +++ b/npm_modules/cli/debugger/devtools-panel.css @@ -616,6 +616,189 @@ button { word-break: break-word; } +.runtime-state-toolbar { + justify-content: flex-start; + gap: 9px; +} + +.runtime-state-summary { + color: var(--muted); + font-size: 10px; + font-weight: 400; + white-space: nowrap; +} + +.runtime-state-filter { + width: min(320px, 48vw); + min-width: 120px; + height: 23px; + margin-left: auto; + padding: 0 7px; + border: 1px solid var(--border); + border-radius: 3px; + outline: none; + background: var(--background); + color: var(--text); + font-size: 11px; +} + +.runtime-state-filter:focus { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.runtime-state-content { + padding: 0; + font-family: var(--mono); + font-size: 11px; +} + +.runtime-state-component { + position: relative; + border-bottom: 1px solid var(--border-soft); +} + +.runtime-state-component-details { + min-width: 0; +} + +.runtime-state-component-summary, +.runtime-state-value-summary { + display: flex; + min-width: 0; + align-items: center; + list-style: none; + cursor: pointer; +} + +.runtime-state-component-summary { + min-height: 34px; + padding: 4px 68px 4px 9px; + gap: 7px; +} + +.runtime-state-component-summary::-webkit-details-marker, +.runtime-state-value-summary::-webkit-details-marker { + display: none; +} + +.runtime-state-component-summary:hover, +.runtime-state-value-summary:hover, +.runtime-state-inspect:hover, +.runtime-state-inspect:focus-visible { + background: var(--surface-hover); +} + +.runtime-state-disclosure { + width: 10px; + flex: 0 0 auto; + color: var(--muted); +} + +.runtime-state-component-details[open] > .runtime-state-component-summary > .runtime-state-disclosure, +details[open] > .runtime-state-value-summary > .runtime-state-disclosure { + transform: rotate(90deg); +} + +.runtime-state-component-name { + min-width: 0; + overflow: hidden; + color: var(--tag); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-state-component-key, +.runtime-state-component-description, +.runtime-state-limit { + color: var(--muted); +} + +.runtime-state-component-key, +.runtime-state-component-description { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-state-component-description { + margin-left: auto; +} + +.runtime-state-inspect { + position: absolute; + top: 5px; + right: 8px; + flex: 0 0 auto; + padding: 3px 6px; + border: 0; + border-radius: 3px; + background: transparent; + color: var(--accent); +} + +.runtime-state-component-body { + padding: 4px 10px 9px 25px; + border-top: 1px solid var(--border-soft); +} + +.runtime-state-value-row { + min-height: 20px; + overflow-wrap: anywhere; +} + +.runtime-state-value-details { + margin-left: 10px; +} + +.runtime-state-value-summary { + min-height: 20px; + gap: 4px; +} + +.runtime-state-value-children { + margin-left: 10px; + padding-left: 8px; + border-left: 1px solid var(--border-soft); +} + +.runtime-state-value-key { + color: var(--attribute); +} + +.runtime-state-value-string { + color: var(--string); +} + +.runtime-state-value-number, +.runtime-state-value-boolean, +.runtime-state-value-null { + color: var(--number); +} + +.runtime-state-value-description { + color: var(--muted); +} + +.runtime-state-raw { + max-height: 260px; + margin: 0; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.runtime-state-inspector { + margin-bottom: 12px; +} + +.runtime-state-limit { + padding: 7px 0; + font-style: italic; +} + .breadcrumb-bar { padding: 0 8px; border-top: 1px solid var(--border); diff --git a/npm_modules/cli/debugger/devtools-panel.html b/npm_modules/cli/debugger/devtools-panel.html index 9436bbbc..ff13df10 100644 --- a/npm_modules/cli/debugger/devtools-panel.html +++ b/npm_modules/cli/debugger/devtools-panel.html @@ -22,6 +22,18 @@ > Elements + - - + + + +
-
+
+ +
componentPropertyEditOperationGeneration !== null && @@ -908,13 +963,20 @@ async function refreshSnapshotInternal(componentPropertyEditOperationGeneration) targetSupports('components') && targetSupports('snapshot') && ((!state.componentPropertyEdit.focused && !state.componentPropertyEdit.pending) || - componentPropertyEditOwnsRefresh()); - if (!refreshIsAllowed()) return; + componentPropertyEditOwnsRefresh()) && + !runtimeStatePresentationHasFocus(); + if (!refreshIsAllowed()) { + return runtimeStatePresentationHasFocus() ? SNAPSHOT_REFRESH_RUNTIME_STATE_DEFERRED : SNAPSHOT_REFRESH_SKIPPED; + } if (state.refreshPending) { const activeRequestCompletion = state.snapshotRequestCompletion; - if (!componentPropertyEditOwnsRefresh() || activeRequestCompletion === null) return; + if (!componentPropertyEditOwnsRefresh() || activeRequestCompletion === null) { + return SNAPSHOT_REFRESH_SKIPPED; + } await activeRequestCompletion; - if (!refreshIsAllowed() || state.refreshPending) return; + if (!refreshIsAllowed() || state.refreshPending) { + return runtimeStatePresentationHasFocus() ? SNAPSHOT_REFRESH_RUNTIME_STATE_DEFERRED : SNAPSHOT_REFRESH_SKIPPED; + } } state.refreshPending = true; let resolveRequestCompletion; @@ -931,12 +993,14 @@ async function refreshSnapshotInternal(componentPropertyEditOperationGeneration) state.snapshotRequestGeneration === requestGeneration; try { const snapshot = await requestJson('/api/devtools/snapshot', targetIdentityParameters(requestTarget), {}); + if (!requestIsCurrent()) return SNAPSHOT_REFRESH_SKIPPED; + if (runtimeStatePresentationHasFocus()) return SNAPSHOT_REFRESH_RUNTIME_STATE_DEFERRED; if ( - !requestIsCurrent() || - ((state.componentPropertyEdit.focused || state.componentPropertyEdit.pending) && - !componentPropertyEditOwnsRefresh()) - ) - return; + (state.componentPropertyEdit.focused || state.componentPropertyEdit.pending) && + !componentPropertyEditOwnsRefresh() + ) { + return SNAPSHOT_REFRESH_SKIPPED; + } if ( snapshot.target?.id === requestTarget.id && Array.isArray(snapshot.target.capabilities) && @@ -948,8 +1012,10 @@ async function refreshSnapshotInternal(componentPropertyEditOperationGeneration) } snapshot.tree = valdiDebuggerTreeModel.restoreTree(snapshot.tree); const wasEmpty = !state.snapshot?.tree; + const previousSelectedNodeId = state.selectedNodeId; const shouldClearHighlight = state.hoveredNodeId !== null || state.highlightMayBeActive; state.snapshot = snapshot; + state.runtimeState.inspectGeneration++; state.componentPropertyEdit.error = null; state.snapshotGeneration++; if (state.highlightTimer) window.clearTimeout(state.highlightTimer); @@ -975,9 +1041,14 @@ async function refreshSnapshotInternal(componentPropertyEditOperationGeneration) state.selectedNodeId = nodeId(chooseInitialNode(snapshot.tree)); revealPath(state.selectedNodeId); } + if (state.selectedNodeId !== previousSelectedNodeId) { + resetRuntimeStateForSelectionChange(state.selectedNodeId); + } render(); + return SNAPSHOT_REFRESH_APPLIED; } catch (error) { if (requestIsCurrent()) reportError(error); + return SNAPSHOT_REFRESH_SKIPPED; } finally { if (state.snapshotRequestCompletion === requestCompletion) { state.snapshotRequestCompletion = null; @@ -994,7 +1065,8 @@ function startRefreshTimer() { if (isDirectMode()) void refreshTargetRegistry(); if (!state.autoRefresh) return; if (state.componentPropertyEdit.focused || state.componentPropertyEdit.pending) return; - if (state.activeSection === 'elements') void refreshSnapshot(); + if (runtimeStatePresentationHasFocus()) return; + if (state.activeSection === 'elements' || state.activeSection === 'state') void refreshSnapshot(); if (state.activeSection === 'performance') void refreshPerformance({ silent: true }); }, 1200); } @@ -1100,6 +1172,621 @@ function propertyRows(attributes, options) { .join(''); } +function runtimeStateParseError(message) { + return new Error(`Runtime state ${message}.`); +} + +function runtimeStateKeywordAt(source, offset, keyword) { + return source.startsWith(keyword, offset) && !/[A-Za-z0-9_$]/.test(source[offset + keyword.length] || ''); +} + +function readRuntimeStateToken(source, parser) { + while (parser.offset < source.length && /[\t\n\r ]/.test(source[parser.offset])) parser.offset++; + if (parser.offset >= source.length) return { type: 'end' }; + parser.tokens++; + if (parser.tokens > MAX_RUNTIME_STATE_TOKENS) throw runtimeStateParseError('exceeds the token limit'); + if (parser.sourceType !== RUNTIME_STATE_SOURCE_WEB) { + throw runtimeStateParseError('cannot structurally parse a native snapshot'); + } + + const character = source[parser.offset]; + if ('{}[]:,'.includes(character)) { + parser.offset++; + return { type: 'punctuation', value: character }; + } + if (character === '"') { + const start = parser.offset; + parser.offset++; + let closed = false; + while (parser.offset < source.length) { + const code = source.charCodeAt(parser.offset); + if (code < 0x20) throw runtimeStateParseError('contains an invalid string'); + if (source[parser.offset] === '"') { + parser.offset++; + closed = true; + break; + } + if (source[parser.offset] !== '\\') { + parser.offset++; + continue; + } + parser.offset++; + const escape = source[parser.offset]; + if ('"\\/bfnrt'.includes(escape)) { + parser.offset++; + continue; + } + if (escape !== 'u' || !/^[0-9a-fA-F]{4}$/.test(source.slice(parser.offset + 1, parser.offset + 5))) { + throw runtimeStateParseError('contains an invalid string escape'); + } + parser.offset += 5; + } + if (!closed) throw runtimeStateParseError('contains an unterminated string'); + let value; + try { + value = JSON.parse(source.slice(start, parser.offset)); + } catch (_error) { + throw runtimeStateParseError('contains an invalid string'); + } + return { type: 'string', value }; + } + if (runtimeStateKeywordAt(source, parser.offset, 'true')) { + parser.offset += 4; + return { type: 'value', value: true }; + } + if (runtimeStateKeywordAt(source, parser.offset, 'false')) { + parser.offset += 5; + return { type: 'value', value: false }; + } + if (runtimeStateKeywordAt(source, parser.offset, 'null')) { + parser.offset += 4; + return { type: 'value', value: null }; + } + + const start = parser.offset; + if (source[parser.offset] === '-') parser.offset++; + if (source[parser.offset] === '0') { + parser.offset++; + if (/\d/.test(source[parser.offset] || '')) throw runtimeStateParseError('contains an invalid number'); + } else if (/[1-9]/.test(source[parser.offset] || '')) { + while (/\d/.test(source[parser.offset] || '')) parser.offset++; + } else { + throw runtimeStateParseError('contains an unsupported token'); + } + if (source[parser.offset] === '.') { + parser.offset++; + if (!/\d/.test(source[parser.offset] || '')) throw runtimeStateParseError('contains an invalid number'); + while (/\d/.test(source[parser.offset] || '')) parser.offset++; + } + if (source[parser.offset] === 'e' || source[parser.offset] === 'E') { + parser.offset++; + if (source[parser.offset] === '+' || source[parser.offset] === '-') parser.offset++; + if (!/\d/.test(source[parser.offset] || '')) throw runtimeStateParseError('contains an invalid number'); + while (/\d/.test(source[parser.offset] || '')) parser.offset++; + } + const value = Number(source.slice(start, parser.offset)); + if (!Number.isFinite(value)) throw runtimeStateParseError('contains a non-finite number'); + return { type: 'value', value }; +} + +function parseRuntimeState(source, sourceType) { + if (typeof source !== 'string') { + return { error: 'Runtime state is not a serialized document.', parsed: false, value: null }; + } + if (source.length === 0 || source.length > MAX_RUNTIME_STATE_CHARACTERS) { + return { + error: `Runtime state must contain between 1 and ${MAX_RUNTIME_STATE_CHARACTERS} characters.`, + parsed: false, + value: null, + }; + } + if (sourceType !== RUNTIME_STATE_SOURCE_NATIVE && sourceType !== RUNTIME_STATE_SOURCE_WEB) { + return { error: 'Runtime state has an unknown snapshot source.', parsed: false, value: null }; + } + if (sourceType === RUNTIME_STATE_SOURCE_NATIVE) { + return { + error: 'Native runtime state is displayed as escaped raw text.', + parsed: false, + value: null, + }; + } + + const parser = { offset: 0, sourceType, tokens: 0 }; + const stack = []; + let root; + let rootSet = false; + + const setValue = value => { + if (stack.length === 0) { + if (rootSet) throw runtimeStateParseError('contains trailing data'); + root = value; + rootSet = true; + return; + } + const frame = stack[stack.length - 1]; + if (frame.type === 'array') { + if (frame.stage !== 'initialOrEnd' && frame.stage !== 'value') { + throw runtimeStateParseError('contains an unexpected array value'); + } + if (frame.entries >= MAX_RUNTIME_STATE_CONTAINER_ENTRIES) { + throw runtimeStateParseError('exceeds the per-container entry limit'); + } + frame.values.push(value); + frame.entries++; + frame.stage = 'commaOrEnd'; + return; + } + if (frame.stage !== 'value' || frame.key === null) { + throw runtimeStateParseError('contains an unexpected object value'); + } + if (frame.entries >= MAX_RUNTIME_STATE_CONTAINER_ENTRIES) { + throw runtimeStateParseError('exceeds the per-container entry limit'); + } + if (Object.prototype.hasOwnProperty.call(frame.target, frame.key)) { + throw runtimeStateParseError('contains a duplicate object key'); + } + Object.defineProperty(frame.target, frame.key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); + frame.entries++; + frame.key = null; + frame.stage = 'commaOrEnd'; + }; + + const beginContainer = type => { + if (stack.length >= MAX_RUNTIME_STATE_DEPTH) throw runtimeStateParseError('exceeds the depth limit'); + const target = type === 'array' ? [] : Object.create(null); + setValue(target); + stack.push({ entries: 0, key: null, stage: 'initialOrEnd', target, type, values: target }); + }; + + const acceptValueToken = token => { + if (token.type === 'value' || token.type === 'string') { + setValue(token.value); + return true; + } + if (token.type === 'punctuation' && token.value === '{') { + beginContainer('object'); + return true; + } + if (token.type === 'punctuation' && token.value === '[') { + beginContainer('array'); + return true; + } + return false; + }; + + try { + while (true) { + const token = readRuntimeStateToken(source, parser); + if (token.type === 'end') { + if (!rootSet) throw runtimeStateParseError('is empty'); + if (stack.length > 0) throw runtimeStateParseError('contains an unterminated container'); + return { error: null, parsed: true, value: root }; + } + if (stack.length === 0) { + if (rootSet || !acceptValueToken(token)) throw runtimeStateParseError('contains trailing data'); + continue; + } + + const frame = stack[stack.length - 1]; + if (frame.type === 'object') { + if (frame.stage === 'initialOrEnd' || frame.stage === 'key') { + if (token.type === 'punctuation' && token.value === '}' && frame.stage === 'initialOrEnd') { + stack.pop(); + continue; + } + if (token.type !== 'string') { + throw runtimeStateParseError('contains an invalid object key'); + } + if (token.value.length > MAX_RUNTIME_STATE_KEY_CHARACTERS) { + throw runtimeStateParseError('contains an overlong object key'); + } + frame.key = token.value; + frame.stage = 'colon'; + continue; + } + if (frame.stage === 'colon') { + if (token.type !== 'punctuation' || token.value !== ':') { + throw runtimeStateParseError('is missing an object value separator'); + } + frame.stage = 'value'; + continue; + } + if (frame.stage === 'value') { + if (!acceptValueToken(token)) throw runtimeStateParseError('contains an invalid object value'); + continue; + } + if (token.type === 'punctuation' && token.value === ',') { + frame.stage = 'key'; + continue; + } + if (token.type === 'punctuation' && token.value === '}') { + stack.pop(); + continue; + } + throw runtimeStateParseError('contains an invalid object separator'); + } + + if (frame.stage === 'initialOrEnd') { + if (token.type === 'punctuation' && token.value === ']') { + stack.pop(); + continue; + } + if (!acceptValueToken(token)) throw runtimeStateParseError('contains an invalid array value'); + continue; + } + if (frame.stage === 'value') { + if (!acceptValueToken(token)) throw runtimeStateParseError('contains an invalid array value'); + continue; + } + if (token.type === 'punctuation' && token.value === ',') { + frame.stage = 'value'; + continue; + } + if (token.type === 'punctuation' && token.value === ']') { + stack.pop(); + continue; + } + throw runtimeStateParseError('contains an invalid array separator'); + } + } catch (error) { + return { + error: error instanceof Error ? error.message : 'Runtime state could not be parsed.', + parsed: false, + value: null, + }; + } +} + +function runtimeStateOwnDataValue(source, propertyName) { + if (typeof source !== 'object' || source === null) return null; + try { + const descriptor = Object.getOwnPropertyDescriptor(source, propertyName); + if (descriptor?.enumerable !== true || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + return null; + } + return { value: descriptor.value }; + } catch (_error) { + return null; + } +} + +function runtimeStateExactNodeId(value) { + if (typeof value === 'string' && value.length > 0) return value; + if (Number.isSafeInteger(value) && value >= 0) return String(value); + return null; +} + +function runtimeStateSelectableNodeId(node) { + if (typeof node !== 'object' || node === null) return null; + try { + const nodeIdDescriptor = Object.getOwnPropertyDescriptor(node, 'id'); + if (nodeIdDescriptor !== undefined) { + if (nodeIdDescriptor.enumerable !== true || !Object.prototype.hasOwnProperty.call(nodeIdDescriptor, 'value')) { + return null; + } + return runtimeStateExactNodeId(nodeIdDescriptor.value); + } + } catch (_error) { + return null; + } + const elementValue = runtimeStateOwnDataValue(node, 'element')?.value; + return runtimeStateExactNodeId(runtimeStateOwnDataValue(elementValue, 'id')?.value); +} + +function runtimeStateSourceType() { + return launchIdentity.mode === 'inspected-page' ? RUNTIME_STATE_SOURCE_WEB : RUNTIME_STATE_SOURCE_NATIVE; +} + +function runtimeStateComponentRecord(node, structuralId) { + const componentValue = runtimeStateOwnDataValue(node, 'component')?.value; + const stateValue = runtimeStateOwnDataValue(componentValue, 'state')?.value; + if (typeof stateValue !== 'string' || stateValue.length === 0 || stateValue.length > MAX_RUNTIME_STATE_CHARACTERS) { + return null; + } + const sourceType = runtimeStateSourceType(); + const nameValue = runtimeStateOwnDataValue(componentValue, 'name')?.value; + const componentKeyValue = runtimeStateOwnDataValue(componentValue, 'key')?.value; + const nodeKeyValue = runtimeStateOwnDataValue(node, 'key')?.value; + const keyValue = sourceType === RUNTIME_STATE_SOURCE_WEB ? componentKeyValue : nodeKeyValue; + const tagValue = runtimeStateOwnDataValue(node, 'tag')?.value; + return { + key: typeof keyValue === 'string' ? keyValue : '', + name: + typeof nameValue === 'string' && nameValue.length > 0 + ? nameValue + : typeof tagValue === 'string' && tagValue.length > 0 + ? tagValue + : 'Component', + node, + selectableNodeId: runtimeStateSelectableNodeId(node), + source: stateValue, + sourceType, + structuralId, + }; +} + +function collectRuntimeStateComponents(root) { + const exactSelectableNodes = new Map(); + const records = []; + const structuralPaths = new WeakMap(); + let truncated = false; + walk(root, (node, ancestors, _depth, sourceChildIndex) => { + let structuralPath; + if (ancestors.length === 0) { + structuralPath = []; + } else { + const parentPath = structuralPaths.get(ancestors[ancestors.length - 1]); + if (!Array.isArray(parentPath) || !Number.isSafeInteger(sourceChildIndex) || sourceChildIndex < 0) { + return false; + } + structuralPath = [...parentPath, sourceChildIndex]; + } + structuralPaths.set(node, structuralPath); + const selectableNodeId = runtimeStateSelectableNodeId(node); + if (selectableNodeId !== null) { + if (exactSelectableNodes.has(selectableNodeId)) exactSelectableNodes.set(selectableNodeId, null); + else exactSelectableNodes.set(selectableNodeId, node); + } + const record = runtimeStateComponentRecord(node, JSON.stringify(structuralPath)); + if (record === null) return true; + if (records.length >= MAX_RUNTIME_STATE_COMPONENT_ROWS) { + truncated = true; + return true; + } + records.push(record); + return true; + }); + for (const record of records) { + if (record.selectableNodeId !== null && exactSelectableNodes.get(record.selectableNodeId) !== record.node) { + record.selectableNodeId = null; + } + } + return { records, truncated }; +} + +function findRuntimeStateRecordByStructuralId(structuralId) { + return ( + collectRuntimeStateComponents(state.snapshot?.tree).records.find(record => record.structuralId === structuralId) || + null + ); +} + +function runtimeStateContainerEntries(value) { + if (Array.isArray(value)) return value.map((entry, index) => [String(index), entry]); + if (typeof value !== 'object' || value === null) return []; + return Object.keys(value).map(key => [key, Object.getOwnPropertyDescriptor(value, key)?.value]); +} + +function runtimeStateDescription(value) { + if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`; + if (typeof value === 'object' && value !== null) { + const count = Object.keys(value).length; + return `${count} field${count === 1 ? '' : 's'}`; + } + return typeof value; +} + +function renderRuntimeStatePrimitive(value) { + if (value === null) return 'null'; + const type = typeof value; + const serialized = type === 'string' ? JSON.stringify(value) : String(value); + return `${escapeHtml(serialized)}`; +} + +function runtimeStateValuePath(scope, componentId, segments) { + return JSON.stringify([scope, componentId, ...segments]); +} + +function renderRuntimeStateEntries(value, context, segments) { + const entries = runtimeStateContainerEntries(value); + if (entries.length === 0) return '
This state container is empty.
'; + const rows = []; + for (const [key, entryValue] of entries) { + if (context.rows >= MAX_RUNTIME_STATE_VALUE_ROWS) { + context.truncated = true; + break; + } + context.rows++; + const entrySegments = [...segments, key]; + const keyLabel = Array.isArray(value) ? `[${key}]` : key; + if (typeof entryValue !== 'object' || entryValue === null) { + rows.push( + `
${escapeHtml(keyLabel)}: ${renderRuntimeStatePrimitive(entryValue)}
`, + ); + continue; + } + const path = runtimeStateValuePath(context.scope, context.componentId, entrySegments); + const expanded = context.expandedPaths.has(path); + rows.push(` +
+ ${escapeHtml(keyLabel)}: ${escapeHtml(runtimeStateDescription(entryValue))} + ${expanded ? `
${renderRuntimeStateEntries(entryValue, context, entrySegments)}
` : ''} +
+ `); + } + if (context.truncated && !context.limitRendered) { + context.limitRendered = true; + rows.push('
Additional state rows were omitted.
'); + } + return rows.join(''); +} + +function renderRuntimeStateDocument(record, parsed, context) { + if (!parsed.parsed || typeof parsed.value !== 'object' || parsed.value === null) { + return `
${escapeHtml(parsed.error || 'Runtime state is not an object document.')}
${escapeHtml(record.source)}
`; + } + return renderRuntimeStateEntries(parsed.value, context, []); +} + +function hydrateRuntimeStateInspectButtons(models) { + const buttons = elements.stateContent?.querySelectorAll?.('[data-runtime-state-inspect-slot]') || []; + for (const button of buttons) { + const index = Number(button.dataset.runtimeStateInspectSlot); + const model = Number.isSafeInteger(index) && index >= 0 ? models[index] : undefined; + if (!model) continue; + delete button.dataset.runtimeStateInspectSlot; + button.dataset.runtimeStateInspect = ''; + runtimeStateInspectBindings.set(button, model); + } +} + +function renderRuntimeStateSection() { + if (!elements.stateContent || !elements.stateSummary) return; + const renderGeneration = ++state.runtimeState.inspectGeneration; + const snapshotGeneration = state.snapshotGeneration; + const collected = collectRuntimeStateComponents(state.snapshot?.tree); + const normalizedSearch = state.runtimeState.search.trim().toLowerCase(); + const records = normalizedSearch + ? collected.records.filter(record => + `${record.name} ${record.key} ${record.source}`.toLowerCase().includes(normalizedSearch), + ) + : collected.records; + elements.stateSummary.textContent = `${records.length}${normalizedSearch ? ` of ${collected.records.length}` : ''} component${collected.records.length === 1 ? '' : 's'}${collected.truncated ? ' · first 500' : ''}`; + if (!state.snapshot?.tree) { + elements.stateContent.innerHTML = '
Waiting for the inspected component hierarchy…
'; + return; + } + if (records.length === 0) { + elements.stateContent.innerHTML = `
${normalizedSearch ? 'No component state matches this filter.' : 'No mounted component has published bounded runtime state.'}
`; + return; + } + + const inspectModels = []; + const renderContext = { + componentId: '', + expandedPaths: state.runtimeState.expandedMainValues, + rows: 0, + limitRendered: false, + scope: 'main', + truncated: false, + }; + const markup = records + .map(record => { + const expanded = state.runtimeState.expandedComponents.has(record.structuralId); + const parsed = parseRuntimeState(record.source, record.sourceType); + let inspectButton = ''; + if (record.selectableNodeId !== null) { + const inspectIndex = + inspectModels.push({ + generation: renderGeneration, + node: record.node, + selectableNodeId: record.selectableNodeId, + snapshotGeneration, + source: record.source, + structuralId: record.structuralId, + }) - 1; + inspectButton = ``; + } + renderContext.componentId = record.structuralId; + const description = parsed.parsed ? runtimeStateDescription(parsed.value) : 'bounded raw snapshot'; + const body = expanded + ? `
${renderRuntimeStateDocument(record, parsed, renderContext)}
` + : ''; + return ` +
+
+ ${escapeHtml(record.name)}${record.key ? `${escapeHtml(record.key)}` : ''}${escapeHtml(description)} + ${body} +
+ ${inspectButton} +
+ `; + }) + .join(''); + elements.stateContent.innerHTML = markup; + hydrateRuntimeStateInspectButtons(inspectModels); +} + +function renderSelectedRuntimeState(node) { + resetRuntimeStateForSelectionChange(nodeId(node)); + const record = runtimeStateComponentRecord(node, JSON.stringify(['selected'])); + if (record === null) { + return '
This selected component has no bounded runtime state snapshot.
'; + } + const parsed = parseRuntimeState(record.source, record.sourceType); + const context = { + componentId: record.structuralId, + expandedPaths: state.runtimeState.expandedInspectorValues, + rows: 0, + limitRendered: false, + scope: 'inspector', + truncated: false, + }; + return `
Component state ${escapeHtml(record.name)}
${renderRuntimeStateDocument(record, parsed, context)}
`; +} + +function inspectRuntimeStateBinding(binding) { + if ( + binding?.generation !== state.runtimeState.inspectGeneration || + binding.snapshotGeneration !== state.snapshotGeneration + ) { + return false; + } + const currentRecord = findRuntimeStateRecordByStructuralId(binding.structuralId); + if ( + currentRecord?.node !== binding.node || + currentRecord.selectableNodeId !== binding.selectableNodeId || + currentRecord.source !== binding.source + ) { + return false; + } + setActiveSection('elements'); + selectNode(binding.selectableNodeId); + setActiveDetail('state'); + elements.detailTabs.find(tab => tab.dataset.detail === 'state')?.focus?.({ preventScroll: true }); + return true; +} + +function restoreRuntimeStateDisclosureFocus(scope, componentId, valuePath) { + const container = scope === 'inspector' ? elements.inspector : elements.stateContent; + const detailsElements = container?.querySelectorAll?.('details') || []; + for (const candidate of detailsElements) { + if ( + (componentId !== null && candidate.dataset.runtimeStateComponentId === componentId) || + (valuePath !== null && candidate.dataset.runtimeStateValuePath === valuePath) + ) { + candidate.querySelector?.('summary')?.focus?.({ preventScroll: true }); + return; + } + } +} + +function updateRuntimeStateDisclosure(details) { + if (!details?.dataset) return; + const valuePath = details.dataset.runtimeStateValuePath; + if (valuePath) { + const summary = details.querySelector?.('summary'); + const restoreFocus = summary !== null && summary !== undefined && summary === document.activeElement; + const scope = details.dataset.runtimeStateValueScope === 'inspector' ? 'inspector' : 'main'; + const expandedPaths = + scope === 'inspector' ? state.runtimeState.expandedInspectorValues : state.runtimeState.expandedMainValues; + if (details.open === expandedPaths.has(valuePath)) return; + if (details.open) expandedPaths.add(valuePath); + else expandedPaths.delete(valuePath); + if (scope === 'inspector') renderInspector(); + else renderRuntimeStateSection(); + if (restoreFocus) restoreRuntimeStateDisclosureFocus(scope, null, valuePath); + return; + } + const structuralId = details.dataset.runtimeStateComponentId; + if (!structuralId) return; + const summary = details.querySelector?.('summary'); + const restoreFocus = summary !== null && summary !== undefined && summary === document.activeElement; + const expanded = state.runtimeState.expandedComponents.has(structuralId); + if (details.open === expanded) return; + const record = findRuntimeStateRecordByStructuralId(structuralId); + if (details.open && record === null) return; + if (details.open) state.runtimeState.expandedComponents.add(structuralId); + else state.runtimeState.expandedComponents.delete(structuralId); + renderRuntimeStateSection(); + if (restoreFocus) restoreRuntimeStateDisclosureFocus('main', structuralId, null); +} + function componentPropertyEditMetadata(node, propertyName, value) { if ( launchIdentity.mode !== 'inspected-page' || @@ -1279,6 +1966,7 @@ async function submitComponentPropertyEdit(componentId, propertyName, componentT renderInspector(); let updated = false; let refreshed = false; + let refreshDeferredForRuntimeState = false; try { const result = await requestJson( '/api/devtools/component-property', @@ -1297,9 +1985,9 @@ async function submitComponentPropertyEdit(componentId, propertyName, componentT if (requestIsCurrent()) { updated = result.updated === true; if (updated) { - const previousSnapshotGeneration = state.snapshotGeneration; - await refreshSnapshotInternal(operationGeneration); - refreshed = state.snapshotGeneration !== previousSnapshotGeneration; + const refreshOutcome = await refreshSnapshotInternal(operationGeneration); + refreshed = refreshOutcome === SNAPSHOT_REFRESH_APPLIED; + refreshDeferredForRuntimeState = refreshOutcome === SNAPSHOT_REFRESH_RUNTIME_STATE_DEFERRED; } } } catch (error) { @@ -1310,10 +1998,10 @@ async function submitComponentPropertyEdit(componentId, propertyName, componentT if (operationIsCurrent()) { state.componentPropertyEdit.pending = false; state.componentPropertyEdit.focused = false; - if (updated && !refreshed && state.componentPropertyEdit.error === null) { + if (updated && !refreshed && !refreshDeferredForRuntimeState && state.componentPropertyEdit.error === null) { state.componentPropertyEdit.error = COMPONENT_PROPERTY_EDIT_ERROR; } - renderInspector(); + if (!runtimeStatePresentationHasFocus()) renderInspector(); } } } @@ -1437,11 +2125,15 @@ function renderInspector() { const renderedNode = inspectedNode(node); const componentPropertyEditorModels = []; - const componentProperties = renderComponentProperties(node, componentPropertyEditorModels); const renderMarkup = markup => { elements.inspector.innerHTML = markup; hydrateComponentPropertyEditors(componentPropertyEditorModels); }; + if (state.activeDetail === 'state') { + renderMarkup(renderSelectedRuntimeState(node)); + return; + } + const componentProperties = renderComponentProperties(node, componentPropertyEditorModels); if (node.component && renderedNode === node) { renderMarkup( `
Valdi component ${escapeHtml(node.tag)}
${propertyRows(componentMetadata(node), { css: false })}${componentProperties}
This component does not currently render a backing element.
`, @@ -1481,12 +2173,15 @@ function render() { renderTree(); renderInspector(); renderBreadcrumbs(); + if (state.activeSection === 'state') renderRuntimeStateSection(); } function selectNode(id) { const node = findNode(id); if (!node) return; - state.selectedNodeId = nodeId(node); + const selectedNodeId = nodeId(node); + resetRuntimeStateForSelectionChange(selectedNodeId); + state.selectedNodeId = selectedNodeId; revealPath(state.selectedNodeId); render(); } @@ -2200,6 +2895,10 @@ function setActiveSection(section) { } if (activeSection === 'console') elements.consoleInput.focus(); if (activeSection === 'elements') void refreshSnapshot(); + if (activeSection === 'state') { + renderRuntimeStateSection(); + void refreshSnapshot(); + } if (activeSection === 'performance') { renderPerformance(); void refreshPerformance(); @@ -2229,10 +2928,29 @@ function setActiveDetail(detail) { const selected = tab.dataset.detail === detail; tab.classList.toggle('selected', selected); tab.setAttribute('aria-selected', String(selected)); + tab.tabIndex = selected ? 0 : -1; + if (selected && tab.id) elements.inspector.setAttribute('aria-labelledby', tab.id); } renderInspector(); } +function handleDetailTabNavigation(event) { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + const tabs = elements.detailTabs.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]; + setActiveDetail(nextTab.dataset.detail); + nextTab.focus(); +} + function stopConsoleStream() { if (state.consoleStream) state.consoleStream.close(); state.consoleStream = null; @@ -2384,6 +3102,7 @@ function wireEvents() { } for (const tab of elements.detailTabs) { tab.addEventListener('click', () => setActiveDetail(tab.dataset.detail)); + tab.addEventListener('keydown', handleDetailTabNavigation); } elements.targetSelect.addEventListener('change', () => { if (!isDirectMode()) return; @@ -2456,6 +3175,18 @@ function wireEvents() { state.search = elements.treeFilter.value; renderTree(); }); + elements.stateFilter.addEventListener('input', () => { + state.runtimeState.search = elements.stateFilter.value; + renderRuntimeStateSection(); + }); + elements.stateContent.addEventListener('click', event => { + const button = event.target.closest?.('[data-runtime-state-inspect]'); + if (!button) return; + event.preventDefault(); + event.stopPropagation(); + inspectRuntimeStateBinding(runtimeStateInspectBindings.get(button)); + }); + elements.stateContent.addEventListener('toggle', event => updateRuntimeStateDisclosure(event.target), true); elements.expandButton.addEventListener('click', () => { expandUsefulNodes(state.snapshot?.tree); if (state.selectedNodeId) revealPath(state.selectedNodeId); @@ -2525,6 +3256,7 @@ function wireEvents() { value, ); }); + elements.inspector.addEventListener('toggle', event => updateRuntimeStateDisclosure(event.target), true); elements.breadcrumbs.addEventListener('click', event => { const button = event.target.closest('[data-breadcrumb-id]'); if (button) selectNode(button.dataset.breadcrumbId); @@ -2571,7 +3303,9 @@ function wireEvents() { }); document.addEventListener('visibilitychange', () => { if (!document.hidden && isDirectMode()) void refreshTargetRegistry(); - if (!document.hidden && state.activeSection === 'elements') void refreshSnapshot(); + if (!document.hidden && (state.activeSection === 'elements' || state.activeSection === 'state')) { + 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 b551483a..586a697d 100644 --- a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts +++ b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts @@ -12,6 +12,7 @@ interface DevToolsTreeNode { name: string; properties?: Record; propertyEdits?: Record; + state?: string; }; element?: { attributes: Record; @@ -145,7 +146,7 @@ interface PickerStubElement { addEventListener(type: string, listener: (event: PickerStubEvent) => void): void; append(child: PickerStubElement): void; closest(selector: string): PickerStubElement | null; - contains(): boolean; + contains(element?: PickerStubElement): boolean; dispatch(type: string, properties?: Partial): void; focus(): void; getAttribute(name: string): string | null; @@ -161,6 +162,7 @@ interface PickerStubElement { interface PickerPanel { state: { + activeDetail: string; activeSection: string; consoleEntries: Array<{ kind: string; value: string }>; consoleEntryKeys: Set; @@ -473,12 +475,12 @@ function createPickerHarness(search: string): PickerHarness { return element; } - const mainTabs = ['elements', 'performance', 'console'].map(section => { + const mainTabs = ['elements', 'state', 'performance', 'console'].map(section => { const tab = elementForId(`${section}Tab`); tab.dataset['section'] = section; return tab; }); - const sections = ['elements', 'performance', 'console'].map(section => { + const sections = ['elements', 'state', 'performance', 'console'].map(section => { const panelElement = elementForId(`${section}Section`); panelElement.dataset['panel'] = section; return panelElement; @@ -1507,6 +1509,70 @@ describe('integrated DevTools capability-aware target picker', () => { }); }); + it('defers an edit-owned snapshot while either State surface owns focus', async () => { + for (const focusScope of ['main', 'inspector'] as const) { + const harness = createPickerHarness( + '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456', + ); + harness.panel.state.target = pickerTarget('owl:web-preview', { + capabilities: ['components', 'component-properties', 'component-property-edit', 'snapshot'], + identityMode: 'inspected-page', + platform: 'web', + sessionId: 'web-preview', + state: 'attached', + transport: 'web-preview', + }); + harness.panel.state.snapshot = { tree: editableComponentTree(true, 'a'.repeat(32), 7) }; + harness.panel.state.snapshotGeneration = 7; + harness.panel.state.selectedNodeId = 'component:["7","nested"]'; + const editResponse = harness.queueDeferred('/api/devtools/component-property'); + const edit = harness.panel.submitComponentPropertyEdit( + 'component:["7","nested"]', + 'enabled', + 'a'.repeat(32), + 7, + false, + ); + await flushPickerPromises(); + expect(harness.panel.state.componentPropertyEdit.pending).withContext(focusScope).toBeTrue(); + + const focusedControl = createPickerStubElement(`${focusScope}-state-control`); + const stateSection = requiredPickerElement(harness, 'stateSection'); + const inspector = requiredPickerElement(harness, 'inspector'); + stateSection.contains = () => focusScope === 'main'; + inspector.contains = () => focusScope === 'inspector'; + harness.panel.state.activeDetail = focusScope === 'inspector' ? 'state' : 'styles'; + harness.setActiveElement(focusedControl); + const focusedMarkup = `focused-${focusScope}-state`; + inspector.innerHTML = focusedMarkup; + + editResponse.resolve({ updated: true }); + await edit; + + expect(harness.fetchRequests.filter(request => new URL(request.url).pathname === '/api/devtools/snapshot').length) + .withContext(focusScope) + .toBe(0); + expect(harness.panel.state.componentPropertyEdit.pending).withContext(focusScope).toBeFalse(); + expect(harness.panel.state.componentPropertyEdit.error).withContext(focusScope).toBeNull(); + expect(harness.panel.state.snapshotGeneration).withContext(focusScope).toBe(7); + expect(inspector.innerHTML).withContext(focusScope).toBe(focusedMarkup); + + harness.setActiveElement(null); + harness.queueResponse('/api/devtools/snapshot', { + tree: editableComponentTree(false, 'b'.repeat(32), 8), + }); + await harness.panel.refreshSnapshot(); + + expect(harness.panel.state.snapshotGeneration).withContext(focusScope).toBe(8); + expect( + harness.panel.state.snapshot?.tree.children[0]?.children[0]?.component?.propertyEdits?.['enabled'] + ?.snapshotRevision, + ) + .withContext(focusScope) + .toBe(8); + } + }); + it('rejects an empty numeric editor value through the form submit path', () => { const harness = createPickerHarness( '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456', @@ -2080,19 +2146,22 @@ describe('integrated DevTools capability-aware target picker', () => { await flushPickerPromises(); harness.panel.setActiveSection('elements'); const elementsTab = requiredPickerElement(harness, 'elementsTab'); + const stateTab = requiredPickerElement(harness, 'stateTab'); 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(harness.panel.state.activeSection).toBe('state'); expect(elementsTab.tabIndex).toBe(-1); - expect(consoleTab.tabIndex).toBe(0); - expect(consoleTab.getAttribute('aria-selected')).toBe('true'); + expect(stateTab.tabIndex).toBe(0); + expect(stateTab.getAttribute('aria-selected')).toBe('true'); expect(requiredPickerElement(harness, 'elementsSection').hidden).toBeTrue(); - expect(requiredPickerElement(harness, 'consoleSection').hidden).toBeFalse(); + expect(requiredPickerElement(harness, 'stateSection').hidden).toBeFalse(); + stateTab.dispatch('keydown', { key: 'ArrowRight' }); + expect(harness.panel.state.activeSection).toBe('console'); consoleTab.dispatch('keydown', { key: 'ArrowRight' }); expect(harness.panel.state.activeSection).toBe('elements'); }); @@ -2748,7 +2817,8 @@ 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; }); @@ -2772,7 +2842,8 @@ 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; }); @@ -2791,7 +2862,8 @@ 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; }); @@ -2822,7 +2894,8 @@ 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; }); @@ -2855,7 +2928,8 @@ describe('integrated DevTools performance panel', () => { 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; + | ((response: { ok: boolean; status: number; json(): Promise> }) => void) + | undefined; nextFetchResponse = new Promise(resolve => { resolveStart = resolve; }); @@ -2894,7 +2968,8 @@ describe('integrated DevTools performance panel', () => { 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; }); diff --git a/npm_modules/cli/src/debugger/devtoolsRuntimeState.spec.ts b/npm_modules/cli/src/debugger/devtoolsRuntimeState.spec.ts new file mode 100644 index 00000000..f3d52fdf --- /dev/null +++ b/npm_modules/cli/src/debugger/devtoolsRuntimeState.spec.ts @@ -0,0 +1,916 @@ +import 'jasmine'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Script } from 'node:vm'; + +interface RuntimeNode { + children: RuntimeNode[]; + component?: { + key?: string; + name?: string; + properties?: Record; + state?: string; + }; + element?: { id?: number | string }; + id?: string; + key?: string; + tag: string; +} + +interface RuntimeStateRecord { + key: string; + name: string; + node: RuntimeNode; + selectableNodeId: string | null; + source: string; + sourceType: string; + structuralId: string; +} + +interface RuntimeStateContext { + componentId: string; + expandedPaths: Set; + limitRendered: boolean; + rows: number; + scope: string; + truncated: boolean; +} + +interface RuntimeParseResult { + error: string | null; + parsed: boolean; + value: unknown; +} + +interface RuntimePanel { + elements: { + inspector: StubElement; + stateContent: StubElement; + stateFilter: StubElement; + stateSection: StubElement; + stateSummary: StubElement; + }; + state: { + activeDetail: string; + activeSection: string; + autoRefresh: boolean; + componentPropertyEdit: { focused: boolean; pending: boolean }; + runtimeState: { + expandedComponents: Set; + expandedInspectorValues: Set; + expandedMainValues: Set; + inspectGeneration: number; + inspectorNodeId: string | null; + search: string; + }; + selectedNodeId: string | null; + snapshot: { tree: RuntimeNode } | null; + snapshotGeneration: number; + target: RuntimeTarget | null; + }; + clearTargetPresentation(message: string): void; + collectRuntimeStateComponents(root: RuntimeNode): { records: RuntimeStateRecord[]; truncated: boolean }; + connectToInspectedPage(): Promise; + handleDetailTabNavigation(event: StubEvent): void; + inspectRuntimeStateBinding(binding: Record): boolean; + parseRuntimeState(source: string, sourceType: string): RuntimeParseResult; + readRuntimeStateToken( + source: string, + parser: { offset: number; sourceType: string; tokens: number }, + ): { type: string; value?: unknown }; + renderRuntimeStateEntries(value: unknown, context: RuntimeStateContext, segments: string[]): string; + renderRuntimeStateSection(): void; + refreshSnapshot(): Promise; + runtimeStateComponentRecord(node: RuntimeNode, structuralId: string): RuntimeStateRecord | null; + resetRuntimeStateForSelectionChange(selectedNodeId: string | null): void; + resetRuntimeStateForTargetChange(): void; + setActiveDetail(detail: string): void; + setActiveSection(section: string): void; + startRefreshTimer(): void; + updateRuntimeStateDisclosure(details: StubElement): void; +} + +interface RuntimeTarget { + applicationUrl?: string; + capabilities: string[]; + debuggingPort?: number; + id: string; + name?: string; + sessionId?: string; +} + +interface StubEvent { + currentTarget: StubElement; + key: string; + target: StubElement; + preventDefault(): void; + stopPropagation(): void; +} + +class StubElement { + readonly attributes = new Map(); + readonly children: StubElement[] = []; + readonly classNames = new Set(); + readonly dataset: Record = {}; + readonly listeners = new Map void>>(); + readonly queryResults = new Map(); + readonly closestResults = new Map(); + readonly containedElements = new Set(); + readonly style: Record = {}; + readonly classList = { + contains: (name: string): boolean => this.classNames.has(name), + toggle: (name: string, enabled?: boolean): void => { + if (enabled ?? !this.classNames.has(name)) this.classNames.add(name); + else this.classNames.delete(name); + }, + }; + checked = true; + className = ''; + disabled = false; + focusCount = 0; + hidden = false; + innerHTML = ''; + open = false; + scrollHeight = 0; + scrollTop = 0; + tabIndex = -1; + textContent = ''; + title = ''; + value = ''; + private readonly onFocus: (element: StubElement) => void; + + constructor( + readonly id: string, + onFocus: (element: StubElement) => void, + ) { + this.onFocus = onFocus; + } + + addEventListener(type: string, listener: (event: StubEvent) => void): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + append(child: StubElement): void { + this.children.push(child); + } + + closest(selector: string): StubElement | null { + return this.closestResults.get(selector) ?? null; + } + + contains(element: StubElement): boolean { + if (element === this || this.containedElements.has(element)) return true; + return Array.from(this.containedElements).some(child => child.contains(element)); + } + + dispatch(type: string, properties: Partial = {}): void { + const event: StubEvent = { + currentTarget: this, + key: '', + preventDefault() {}, + stopPropagation() {}, + target: this, + ...properties, + }; + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + focus(): void { + this.focusCount++; + this.onFocus(this); + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + getBoundingClientRect(): { bottom: number; height: number; left: number; right: number; top: number; width: number } { + return { bottom: 600, height: 600, left: 0, right: 800, top: 0, width: 800 }; + } + + querySelector(selector: string): StubElement | null { + return this.querySelectorAll(selector)[0] ?? null; + } + + querySelectorAll(selector: string): StubElement[] { + return this.queryResults.get(selector) ?? []; + } + + removeAttribute(name: string): void { + this.attributes.delete(name); + } + + replaceChildren(...children: StubElement[]): void { + this.children.splice(0, this.children.length, ...children); + } + + scrollIntoView(): void {} + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + setSelectionRange(): void {} +} + +interface RuntimeHarness { + detailTabs: StubElement[]; + fetchPaths: string[]; + panel: RuntimePanel; + deferNextSnapshot(): void; + dispatchDocument(type: string): void; + resolveDeferredSnapshot(): void; + runIntervals(): void; + setActiveElement(element: StubElement | null): void; + setDocumentHidden(hidden: boolean): void; + setTargetPayload(target: RuntimeTarget): void; +} + +function makeRuntimeNode(index: number, source = `{"value":${index}}`): RuntimeNode { + return { + children: [], + component: { key: `key-${index}`, name: `Component${index}`, state: source }, + id: `component-${index}`, + tag: `Component${index}`, + }; +} + +function createRuntimeHarness(search = '?targetId=runtime-state-test'): RuntimeHarness { + 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 intervals: Array<() => void> = []; + const fetchPaths: string[] = []; + const documentListeners = new Map void>>(); + const documentDataset: Record = {}; + let activeElement: StubElement | null = null; + let deferredSnapshotResolve: (() => void) | null = null; + let deferSnapshot = false; + let documentHidden = false; + let targetPayload: RuntimeTarget = { + applicationUrl: 'http://127.0.0.1:54321', + capabilities: ['components', 'snapshot'], + debuggingPort: 54_321, + id: 'new-target', + name: 'New target', + sessionId: 'new-session', + }; + + const elementForId = (id: string): StubElement => { + let element = elements.get(id); + if (element === undefined) { + element = new StubElement(id, focused => { + activeElement = focused; + }); + elements.set(id, element); + } + return element; + }; + const mainTabs = ['elements', 'state', 'performance', 'console'].map(section => { + const tab = elementForId(`${section}Tab`); + tab.dataset['section'] = section; + return tab; + }); + const detailTabs = ['styles', 'computed', 'state', 'dom'].map(detail => { + const tab = elementForId(`${detail}DetailTab`); + tab.dataset['detail'] = detail; + return tab; + }); + const sections = ['elements', 'state', 'performance', 'console'].map(section => { + const sectionElement = elementForId(`${section}Section`); + sectionElement.dataset['panel'] = section; + return sectionElement; + }); + elementForId('stateSection').containedElements.add(elementForId('stateFilter')); + elementForId('stateSection').containedElements.add(elementForId('stateContent')); + const documentObject = { + addEventListener(type: string, listener: () => void) { + const listeners = documentListeners.get(type) ?? []; + listeners.push(listener); + documentListeners.set(type, listeners); + }, + createElement(type: string): StubElement { + return new StubElement(type, focused => { + activeElement = focused; + }); + }, + documentElement: { dataset: documentDataset }, + get activeElement(): StubElement | null { + return activeElement; + }, + getElementById(id: string): StubElement { + return elementForId(id); + }, + get hidden(): boolean { + return documentHidden; + }, + querySelectorAll(selector: string): StubElement[] { + if (selector === '.main-tab') return mainTabs; + if (selector === '.detail-tab') return detailTabs; + if (selector === '.section') return sections; + return []; + }, + }; + const windowObject = { + addEventListener() {}, + clearInterval() {}, + clearTimeout() {}, + confirm: () => true, + location: { origin: 'http://127.0.0.1:18768', search }, + parent: {}, + removeEventListener() {}, + setInterval(callback: () => void): number { + intervals.push(callback); + return intervals.length; + }, + setTimeout(callback: () => void): number { + callback(); + return 1; + }, + }; + class StubEventSource { + addEventListener(): void {} + close(): void {} + } + const panel = new Script( + `${treeModelSource}\n${panelSource}\n({ clearTargetPresentation, collectRuntimeStateComponents, connectToInspectedPage, elements, handleDetailTabNavigation, inspectRuntimeStateBinding, parseRuntimeState, readRuntimeStateToken, refreshSnapshot, renderRuntimeStateEntries, renderRuntimeStateSection, resetRuntimeStateForSelectionChange, resetRuntimeStateForTargetChange, runtimeStateComponentRecord, setActiveDetail, setActiveSection, startRefreshTimer, state, updateRuntimeStateDisclosure })`, + ).runInNewContext({ + Blob, + EventSource: StubEventSource, + URL, + URLSearchParams, + console, + document: documentObject, + fetch: (input: URL | string) => { + const url = new URL(input.toString()); + fetchPaths.push(url.pathname); + const payload = + url.pathname === '/api/devtools/target' + ? { target: targetPayload } + : url.pathname === '/api/devtools/snapshot' + ? { target: targetPayload, tree: makeRuntimeNode(1) } + : {}; + const response = { json: () => Promise.resolve(payload), ok: true, status: 200 }; + if (url.pathname === '/api/devtools/snapshot' && deferSnapshot) { + deferSnapshot = false; + return new Promise(resolve => { + deferredSnapshotResolve = () => resolve(response); + }); + } + return Promise.resolve(response); + }, + navigator: { clipboard: { writeText: () => Promise.resolve() } }, + window: windowObject, + }) as RuntimePanel; + + return { + detailTabs, + deferNextSnapshot() { + deferSnapshot = true; + }, + dispatchDocument(type) { + for (const listener of documentListeners.get(type) ?? []) listener(); + }, + fetchPaths, + panel, + resolveDeferredSnapshot() { + deferredSnapshotResolve?.(); + deferredSnapshotResolve = null; + }, + runIntervals() { + for (const callback of intervals) callback(); + }, + setActiveElement(element) { + activeElement = element; + }, + setDocumentHidden(hidden) { + documentHidden = hidden; + }, + setTargetPayload(target) { + targetPayload = target; + }, + }; +} + +async function flushPromises(): Promise { + for (let index = 0; index < 10; index++) await Promise.resolve(); +} + +function makeValueContext(): RuntimeStateContext { + return { + componentId: 'component', + expandedPaths: new Set(), + limitRendered: false, + rows: 0, + scope: 'main', + truncated: false, + }; +} + +describe('DevTools bounded runtime state', () => { + it('parses strict JSON into null-prototype records without prototype pollution', () => { + const { panel } = createRuntimeHarness(); + const parsed = panel.parseRuntimeState( + '{"__proto__":{"polluted":true},"constructor":{"safe":1},"prototype":{"safe":2}}', + 'web', + ); + const value = parsed.value as Record; + const emptyRecord: Record = {}; + + expect(parsed.parsed).toBeTrue(); + expect(Object.getPrototypeOf(value)).toBeNull(); + expect(Object.getPrototypeOf(value['__proto__'] as object)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(value, '__proto__')).toBeTrue(); + expect(Object.prototype.hasOwnProperty.call(value, 'constructor')).toBeTrue(); + expect(Object.prototype.hasOwnProperty.call(value, 'prototype')).toBeTrue(); + expect(emptyRecord['polluted']).toBeUndefined(); + }); + + it('treats every native debug document as escaped raw-only input', () => { + const { panel } = createRuntimeHarness(); + const nativeDocuments = [ + '{ count: 1, ready: true }', + // An actual key of `real: 0, forged` is indistinguishable from two fields + // because the native debug serializer does not escape property names. + '{ real: 0, forged: true }', + 'Map{first: 1, second: 2}', + 'Set(true, false)', + ', forged: true/>', + '', + ]; + const node = makeRuntimeNode(1); + panel.state.snapshot = { tree: node }; + panel.state.runtimeState.expandedComponents.add('[]'); + + for (const source of nativeDocuments) { + const parsed = panel.parseRuntimeState(source, 'native'); + expect(parsed.parsed).withContext(source).toBeFalse(); + expect(parsed.value).withContext(source).toBeNull(); + expect(parsed.error).withContext(source).toContain('escaped raw text'); + node.component = { state: source }; + panel.renderRuntimeStateSection(); + expect(panel.elements.stateContent.innerHTML).withContext(source).toContain('class="runtime-state-raw"'); + expect(panel.elements.stateContent.innerHTML).withContext(source).not.toContain('runtime-state-value-key'); + } + + node.component = { state: '' }; + panel.renderRuntimeStateSection(); + + expect(panel.elements.stateContent.innerHTML).toContain('class="runtime-state-raw"'); + expect(panel.elements.stateContent.innerHTML).toContain('<error "real: 0, forged: true"/>'); + expect(panel.elements.stateContent.innerHTML).not.toContain('runtime-state-value-key'); + }); + + it('keeps web parsing strict', () => { + const { panel } = createRuntimeHarness(); + + expect(panel.parseRuntimeState('{"same":1,"same":2}', 'web').parsed).toBeFalse(); + expect(panel.parseRuntimeState('{ value: 1 }', 'web').parsed).toBeFalse(); + expect(panel.parseRuntimeState('{"value":"safe"}', 'web').parsed).toBeTrue(); + expect(panel.parseRuntimeState('{"value":"safe"}', 'unknown').parsed).toBeFalse(); + }); + + it('enforces exact raw, depth, entry, key, and token boundaries', () => { + const { panel } = createRuntimeHarness(); + const depth12 = `${'['.repeat(12)}0${']'.repeat(12)}`; + const depth13 = `${'['.repeat(13)}0${']'.repeat(13)}`; + const entries100 = `{${Array.from({ length: 100 }, (_value, index) => `"k${index}":${index}`).join(',')}}`; + const entries101 = `{${Array.from({ length: 101 }, (_value, index) => `"k${index}":${index}`).join(',')}}`; + const key1024 = `{${JSON.stringify('k'.repeat(1024))}:1}`; + const key1025 = `{${JSON.stringify('k'.repeat(1025))}:1}`; + const raw65536 = `"${'x'.repeat(65_534)}"`; + const nearTokenLimit = `[${[ + `[${Array.from({ length: 8 }, () => '0').join(',')}]`, + ...Array.from({ length: 99 }, () => `[${Array.from({ length: 9 }, () => '0').join(',')}]`), + ].join(',')}]`; + const overTokenLimit = `[${Array.from({ length: 100 }, () => `[${Array.from({ length: 9 }, () => '0').join(',')}]`).join(',')}]`; + + expect(panel.parseRuntimeState(depth12, 'web').parsed).toBeTrue(); + expect(panel.parseRuntimeState(depth13, 'web').parsed).toBeFalse(); + expect(panel.parseRuntimeState(entries100, 'web').parsed).toBeTrue(); + expect(panel.parseRuntimeState(entries101, 'web').parsed).toBeFalse(); + expect(panel.parseRuntimeState(key1024, 'web').parsed).toBeTrue(); + expect(panel.parseRuntimeState(key1025, 'web').parsed).toBeFalse(); + expect(raw65536.length).toBe(65_536); + expect(panel.parseRuntimeState(raw65536, 'web').parsed).toBeTrue(); + expect(panel.parseRuntimeState(`${raw65536} `, 'web').parsed).toBeFalse(); + expect(panel.parseRuntimeState(nearTokenLimit, 'web').parsed).toBeTrue(); + expect(panel.parseRuntimeState(overTokenLimit, 'web').parsed).toBeFalse(); + }); + + it('allows exactly 2,000 lexical tokens and rejects token 2,001', () => { + const { panel } = createRuntimeHarness(); + const source = Array.from({ length: 2001 }, () => '0').join(' '); + const parser = { offset: 0, sourceType: 'web', tokens: 0 }; + + for (let index = 0; index < 2000; index++) { + expect(panel.readRuntimeStateToken(source, parser).type).toBe('value'); + } + expect(parser.tokens).toBe(2000); + expect(() => panel.readRuntimeStateToken(source, parser)).toThrowError(/token limit/); + }); + + it('caps component rows at 500 and rendered value rows at 1,000', () => { + const { panel } = createRuntimeHarness(); + const root = makeRuntimeNode(0); + root.children = Array.from({ length: 500 }, (_value, index) => makeRuntimeNode(index + 1)); + + const components = panel.collectRuntimeStateComponents(root); + const context = makeValueContext(); + const markup = panel.renderRuntimeStateEntries( + Array.from({ length: 1001 }, (_value, index) => index), + context, + [], + ); + + expect(components.records.length).toBe(500); + expect(components.truncated).toBeTrue(); + expect(context.rows).toBe(1000); + expect(context.truncated).toBeTrue(); + expect(markup).toContain('Additional state rows were omitted.'); + expect(markup).not.toContain('[1000]'); + }); + + it('keeps repeated native subtrees distinct without offering path-only Inspect actions', () => { + const { panel } = createRuntimeHarness(); + const makeNativeStateNode = (key: string, source: string, children: RuntimeNode[]): RuntimeNode => ({ + children, + component: { state: source }, + key, + tag: key === 'shared-key' ? 'RepeatedComponent' : 'BranchComponent', + }); + const firstRepeated = makeNativeStateNode('shared-key', '{ value: 1 }', []); + const secondRepeated = makeNativeStateNode('shared-key', '{ value: 2 }', []); + const root: RuntimeNode = { + children: [ + makeNativeStateNode('left-branch', '{ branch: 1 }', [firstRepeated]), + makeNativeStateNode('right-branch', '{ branch: 2 }', [secondRepeated]), + ], + key: 'root', + tag: 'Root', + }; + panel.state.snapshot = { tree: root }; + + const records = panel.collectRuntimeStateComponents(root).records; + const repeatedRecords = records.filter(record => record.key === 'shared-key'); + expect(repeatedRecords.map(record => record.structuralId)).toEqual(['[0,0]', '[1,0]']); + expect(repeatedRecords.every(record => record.selectableNodeId === null)).toBeTrue(); + expect(repeatedRecords.every(record => record.sourceType === 'native')).toBeTrue(); + expect(records.map(record => record.key)).toEqual(['left-branch', 'shared-key', 'right-branch', 'shared-key']); + + panel.state.runtimeState.expandedComponents.add('[0,0]'); + panel.renderRuntimeStateSection(); + const markup = panel.elements.stateContent.innerHTML; + expect(markup).toContain('data-runtime-state-component-id="[0,0]" open'); + expect(markup).toContain('data-runtime-state-component-id="[1,0]"'); + expect(markup).not.toContain('data-runtime-state-component-id="[1,0]" open'); + expect((markup.match(/shared-key/g) ?? []).length).toBe(2); + expect(markup).not.toContain('runtime-state-inspect'); + }); + + it('omits Inspect when an explicit snapshot identity is duplicated', () => { + const { panel } = createRuntimeHarness(); + const first = makeRuntimeNode(1); + const second = makeRuntimeNode(2); + first.id = 'duplicate'; + second.id = 'duplicate'; + const root: RuntimeNode = { children: [first, second], id: 'root', tag: 'Root' }; + panel.state.snapshot = { tree: root }; + + const records = panel.collectRuntimeStateComponents(root).records; + expect(records.map(record => record.selectableNodeId)).toEqual([null, null]); + panel.renderRuntimeStateSection(); + expect(panel.elements.stateContent.innerHTML).not.toContain('runtime-state-inspect'); + }); + + it('keeps native parsing independent from an exact element identity', () => { + const { panel } = createRuntimeHarness(); + const node: RuntimeNode = { + children: [], + component: { state: '{"value":"ambiguous native string"}' }, + element: { id: 42 }, + key: 'native-key', + tag: 'NativeComponent', + }; + panel.state.snapshot = { tree: node }; + panel.state.runtimeState.expandedComponents.add('[]'); + + const record = panel.runtimeStateComponentRecord(node, '[]'); + expect(record?.selectableNodeId).toBe('42'); + expect(record?.sourceType).toBe('native'); + expect(record?.key).toBe('native-key'); + panel.renderRuntimeStateSection(); + expect(panel.elements.stateContent.innerHTML).toContain('bounded raw snapshot'); + expect(panel.elements.stateContent.innerHTML).toContain('component 42'); + }); + + it('does not fall back to element identity when an own node identity is malformed', () => { + const { panel } = createRuntimeHarness(); + const node: RuntimeNode = { + children: [], + component: { state: '{ count: 1 }' }, + element: { id: 42 }, + id: '', + key: 'malformed-node-id', + tag: 'NativeComponent', + }; + panel.state.snapshot = { tree: node }; + + expect(panel.runtimeStateComponentRecord(node, '[]')?.selectableNodeId).toBeNull(); + panel.renderRuntimeStateSection(); + expect(panel.elements.stateContent.innerHTML).not.toContain('runtime-state-inspect'); + }); + + it('does not read inherited or accessor-backed snapshot fields in the State UI', () => { + const { panel } = createRuntimeHarness(); + const getter = jasmine.createSpy('stateGetter').and.throwError('must not execute'); + const accessorComponent: Record = { key: 'key', name: 'Accessor' }; + Object.defineProperty(accessorComponent, 'state', { enumerable: true, get: getter }); + const accessorNode = makeRuntimeNode(1); + accessorNode.component = accessorComponent as NonNullable; + const inheritedComponent = Object.create({ state: '{"unsafe":true}' }) as NonNullable; + Object.defineProperties(inheritedComponent, { + key: { enumerable: true, value: 'key' }, + name: { enumerable: true, value: 'Inherited' }, + }); + const inheritedNode = makeRuntimeNode(2); + inheritedNode.component = inheritedComponent; + + expect(panel.runtimeStateComponentRecord(accessorNode, 'accessor')).toBeNull(); + expect(panel.runtimeStateComponentRecord(inheritedNode, 'inherited')).toBeNull(); + expect(getter).not.toHaveBeenCalled(); + }); + + it('keeps State search independent and escapes component names, keys, and raw fallback text', () => { + const { panel } = createRuntimeHarness( + '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321&targetNonce=runtime-state-nonce', + ); + const matching = makeRuntimeNode(1, '{ malformed: }'); + matching.component = { key: '', name: '', state: '{ malformed: }' }; + const other = makeRuntimeNode(2, '{"value":"other"}'); + matching.children = [other]; + panel.state.snapshot = { tree: matching }; + panel.state.runtimeState.search = 'needle'; + panel.state.runtimeState.expandedComponents.add('[]'); + + panel.renderRuntimeStateSection(); + + expect(panel.elements.stateSummary.textContent).toContain('1 of 2 components'); + expect(panel.elements.stateContent.innerHTML).toContain('<script>needle</script>'); + expect(panel.elements.stateContent.innerHTML).toContain('<key>'); + expect(panel.elements.stateContent.innerHTML).toContain('<img src=x>'); + expect(panel.elements.stateContent.innerHTML).not.toContain('