From 012775d60d29fc59eab2ff63e31c0006a06943a9 Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Wed, 26 Aug 2026 04:15:44 -0700 Subject: [PATCH] feat(debugger): add bounded component properties --- npm_modules/cli/debugger/README.md | 13 + npm_modules/cli/debugger/devtools-panel.css | 5 + npm_modules/cli/debugger/devtools-panel.js | 31 +- .../cli/src/debugger/devtoolsPanel.spec.ts | 80 +++- npm_modules/cli/src/debugger/server.spec.ts | 11 +- npm_modules/cli/src/debugger/server.ts | 1 + .../cli/src/debugger/targetRegistry.spec.ts | 3 + .../cli/src/debugger/targetRegistry.ts | 1 + .../src/valdi/valdi_core/src/IRenderer.ts | 2 + .../src/valdi/valdi_core/src/Renderer.ts | 7 +- .../valdi/valdi_test/test/Renderer.spec.ts | 3 + .../src/ValdiWebRendererDelegate.ts | 17 + .../src/debug/ComponentHierarchySnapshot.ts | 46 ++ .../src/debug/DebuggerValueSnapshot.ts | 403 ++++++++++++++++++ .../test/DebuggerValueSnapshot.spec.ts | 131 ++++++ .../test/LegacyWebDebuggerAdapter.spec.ts | 150 ++++++- 16 files changed, 890 insertions(+), 14 deletions(-) create mode 100644 src/valdi_modules/src/valdi/web_renderer/src/debug/DebuggerValueSnapshot.ts create mode 100644 src/valdi_modules/src/valdi/web_renderer/test/DebuggerValueSnapshot.spec.ts diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md index 7cad1589..d20b115f 100644 --- a/npm_modules/cli/debugger/README.md +++ b/npm_modules/cli/debugger/README.md @@ -48,6 +48,19 @@ Important routes: - `/api/devtools/snapshot`, `/api/devtools/highlight`, and `/api/devtools/evaluate`: proxy the explicit web debugger bridge contract through loopback CDP. - `/api/devtools/performance/snapshot` and `/api/devtools/performance/trace/*`: sample the exact web preview and record one bounded global Chromium trace without changing the daemon/Hermes `/api/performance/*` routes. +Web preview targets advertise the `component-properties` capability. Their +Elements snapshots may include a read-only `Valdi props` projection captured +from own enumerable data descriptors only; accessors, inherited fields, and +symbol keys are omitted. Component properties share a 64 KiB UTF-8 budget and +are discarded before the hierarchy if the complete snapshot reaches its +existing envelope limit. JavaScript has no resumable own-key iterator, so +reflecting a Proxy can execute its `ownKeys` and descriptor traps and may +materialize their complete key result before the cap is applied. A throwing or +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. Native targets do not advertise +or emit this data. + Renderer tracing uses the runtime debugger protocol and the existing native trace recorder. Captures are process-wide: the selected context is the capture target used to reach the runtime, not the origin assigned to every event. diff --git a/npm_modules/cli/debugger/devtools-panel.css b/npm_modules/cli/debugger/devtools-panel.css index 54b15a20..86c41eb9 100644 --- a/npm_modules/cli/debugger/devtools-panel.css +++ b/npm_modules/cli/debugger/devtools-panel.css @@ -471,6 +471,11 @@ button { font-weight: 500; } +.component-properties { + margin-bottom: 12px; + border-bottom: 1px solid var(--border); +} + .rule-origin { float: right; color: var(--muted); diff --git a/npm_modules/cli/debugger/devtools-panel.js b/npm_modules/cli/debugger/devtools-panel.js index 83fe2d41..e6558e68 100644 --- a/npm_modules/cli/debugger/devtools-panel.js +++ b/npm_modules/cli/debugger/devtools-panel.js @@ -826,6 +826,7 @@ async function connectToInspectedPage() { } if (previousTargetKey !== nextTargetKey) state.targetGeneration++; state.target = payload.target; + if (previousTargetKey !== null && previousTargetKey !== nextTargetKey) render(); elements.targetName.textContent = state.target.name || 'Valdi application'; elements.targetName.title = state.target.applicationUrl || inspectedUrl; elements.targetMetadata.textContent = `Chromium · :${state.target.debuggingPort}`; @@ -1024,6 +1025,25 @@ function propertyRows(attributes, options) { .join(''); } +function componentMetadata(node) { + if (!node.component) return {}; + return { + ...(node.component.elementId === undefined ? {} : { elementId: node.component.elementId }), + key: node.component.key, + name: node.component.name, + }; +} + +function renderComponentProperties(node) { + if (node.component?.properties === undefined || !targetSupports('component-properties')) return ''; + return ` +
+
Valdi props read only
+
${propertyRows(node.component.properties, { css: false })}
+
+ `; +} + function renderStyles(node) { const attributes = nodeAttributes(node); const domStyle = node.element?.dom?.attributes?.style @@ -1104,23 +1124,24 @@ function renderInspector() { } const renderedNode = inspectedNode(node); + const componentProperties = renderComponentProperties(node); if (node.component && renderedNode === node) { - elements.inspector.innerHTML = `
Valdi component ${escapeHtml(node.tag)}
${propertyRows(node.component, { css: false })}
This component does not currently render a backing element.
`; + elements.inspector.innerHTML = `
Valdi component ${escapeHtml(node.tag)}
${propertyRows(componentMetadata(node), { css: false })}${componentProperties}
This component does not currently render a backing element.
`; return; } if (state.activeDetail === 'styles') { - elements.inspector.innerHTML = renderStyles(renderedNode); + elements.inspector.innerHTML = `${componentProperties}${renderStyles(renderedNode)}`; } else if (state.activeDetail === 'computed') { - elements.inspector.innerHTML = renderComputed(renderedNode); + elements.inspector.innerHTML = `${componentProperties}${renderComputed(renderedNode)}`; } else { const textContent = renderedNode.element?.dom?.textContent ? valdiDebuggerTreeModel.formatValue(renderedNode.element.dom.textContent, 0) : ''; const componentDetails = node.component - ? `
Valdi component ${escapeHtml(node.tag)}
${propertyRows(node.component, { css: false })}` + ? `
Valdi component ${escapeHtml(node.tag)}
${propertyRows(componentMetadata(node), { css: false })}` : ''; - elements.inspector.innerHTML = `${componentDetails}
Rendered <${escapeHtml(valdiDebuggerTreeModel.formatValue(renderedNode.element?.dom?.tagName || 'div', 0))}>
${propertyRows(renderedNode.element?.dom?.attributes, { css: false })}${textContent ? `
Text content
${escapeHtml(textContent)}
` : ''}`; + elements.inspector.innerHTML = `${componentDetails}${componentProperties}
Rendered <${escapeHtml(valdiDebuggerTreeModel.formatValue(renderedNode.element?.dom?.tagName || 'div', 0))}>
${propertyRows(renderedNode.element?.dom?.attributes, { css: false })}${textContent ? `
Text content
${escapeHtml(textContent)}
` : ''}`; } } diff --git a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts index de51813e..50b08f7b 100644 --- a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts +++ b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts @@ -6,7 +6,7 @@ import { Script } from 'node:vm'; interface DevToolsTreeNode { bounds?: { height: number; width: number; x: number; y: number }; children: DevToolsTreeNode[]; - component?: { elementId?: string; key: string; name: string }; + component?: { elementId?: string; key: string; name: string; properties?: Record }; element?: { attributes: Record; dom: { attributes: Record; tagName: string; textContent?: string }; @@ -42,9 +42,11 @@ interface DevToolsHierarchyPanel { selectedNodeId: string | null; snapshot: { tree: DevToolsTreeNode } | null; snapshotGeneration: number; - target: { id: string; sessionId: string } | null; + target: { capabilities?: string[]; id: string; sessionId: string } | null; + targetGeneration: number; }; treeContent: TreeStubElement; + clearTargetPresentation(message: string): void; findNode(id: string): DevToolsTreeNode | null; inspectedNodeId(node: DevToolsTreeNode | null): string | null; queueHighlight(nodeId: string | null): void; @@ -225,7 +227,12 @@ function componentTree(): DevToolsTreeNode { tag: 'label', }, ], - component: { elementId: '8', key: 'nested', name: 'NestedExampleComponent' }, + component: { + elementId: '8', + key: 'nested', + name: 'NestedExampleComponent', + properties: { enabled: true, title: '' }, + }, id: 'component:["7","nested"]', tag: 'NestedExampleComponent', }, @@ -625,7 +632,7 @@ describe('integrated DevTools component hierarchy', () => { }; panel = new Script( - `${treeModelSource}\n${panelSource}\n({ findNode, inspectedNodeId, inspectorContent: elements.inspector, queueHighlight, refreshSnapshot, renderInspector, renderTree, selectNode, state, treeContent: elements.tree })`, + `${treeModelSource}\n${panelSource}\n({ clearTargetPresentation, findNode, inspectedNodeId, inspectorContent: elements.inspector, queueHighlight, refreshSnapshot, renderInspector, renderTree, selectNode, state, treeContent: elements.tree })`, ).runInNewContext({ URL, URLSearchParams, @@ -640,7 +647,11 @@ describe('integrated DevTools component hierarchy', () => { navigator: { clipboard: { writeText: () => Promise.resolve() } }, window, }) as DevToolsHierarchyPanel; - panel.state.target = { id: 'owl:web-preview', sessionId: 'web-preview' }; + panel.state.target = { + capabilities: ['components', 'component-properties', 'snapshot', 'highlight', 'console', 'performance'], + id: 'owl:web-preview', + sessionId: 'web-preview', + }; panel.state.snapshot = { tree: componentTree() }; panel.state.snapshotGeneration = 1; }); @@ -681,6 +692,65 @@ describe('integrated DevTools component hierarchy', () => { expect(panel.inspectorContent.innerHTML).toContain('Text content'); }); + it('renders escaped read-only Valdi props only when the target advertises the capability', () => { + panel.selectNode('component:["7","nested"]'); + + expect(panel.inspectorContent.innerHTML).toContain('aria-label="Valdi props"'); + expect(panel.inspectorContent.innerHTML).toContain('read only'); + expect(panel.inspectorContent.innerHTML).toContain('<img src=x onerror=alert(1)>'); + expect(panel.inspectorContent.innerHTML).not.toContain(' { + const node = panel.findNode('component:["7","nested"]'); + if (!node?.component) throw new Error('Expected the nested component fixture.'); + delete node.component.properties; + panel.selectNode(node.id); + + expect(panel.inspectorContent.innerHTML).not.toContain('aria-label="Valdi props"'); + + node.component.properties = {}; + panel.renderInspector(); + + expect(panel.inspectorContent.innerHTML).toContain('aria-label="Valdi props"'); + expect(panel.inspectorContent.innerHTML).toContain('No properties available.'); + }); + + it('clears properties on target change and drops a stale snapshot response', async () => { + let resolveSnapshot: ((response: { ok: boolean; json(): Promise> }) => void) | undefined; + queuedFetchResponses.push( + new Promise(resolve => { + resolveSnapshot = resolve; + }), + ); + panel.selectNode('component:["7","nested"]'); + const staleRefresh = panel.refreshSnapshot(); + await Promise.resolve(); + + panel.state.targetGeneration++; + panel.clearTargetPresentation('Loading the replacement target…'); + panel.state.target = { + capabilities: ['components', 'component-properties', 'snapshot', 'highlight', 'console', 'performance'], + id: 'replacement-target', + sessionId: 'replacement-session', + }; + panel.renderInspector(); + expect(panel.state.snapshot).toBeNull(); + expect(panel.inspectorContent.innerHTML).not.toContain('onerror'); + + if (resolveSnapshot === undefined) throw new Error('Expected a deferred hierarchy snapshot.'); + resolveSnapshot({ json: () => Promise.resolve({ tree: componentTree() }), ok: true }); + await staleRefresh; + + expect(panel.state.snapshot).toBeNull(); + expect(panel.inspectorContent.innerHTML).not.toContain('onerror'); + }); + it('preserves keyed component selection across updates and safely falls back when it disappears', async () => { panel.selectNode('component:["7","nested"]'); fetchResponse = { tree: componentTree() }; diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts index 58fc211a..07e8e241 100644 --- a/npm_modules/cli/src/debugger/server.spec.ts +++ b/npm_modules/cli/src/debugger/server.spec.ts @@ -901,7 +901,7 @@ describe('debugger server', () => { expect(JSON.parse(matching.body)).toEqual({ target: jasmine.objectContaining({ applicationUrl: 'http://127.0.0.1:54321/index.html?tenant=alpha&mode=dev', - capabilities: ['components', 'snapshot', 'highlight', 'console', 'performance'], + capabilities: ['components', 'component-properties', 'snapshot', 'highlight', 'console', 'performance'], debuggingPort: 9333, id: 'owl:web-preview', identityMode: 'inspected-page', @@ -1022,7 +1022,14 @@ describe('debugger server', () => { expect(second['capabilities']).toEqual(['components', 'snapshot']); expect(second['identityMode']).toBe('target-id'); expect(webTargets[0]?.['identityMode']).toBe('inspected-page'); - expect(webTargets[0]?.['capabilities']).toEqual(['components', 'snapshot', 'highlight', 'console', 'performance']); + expect(webTargets[0]?.['capabilities']).toEqual([ + 'components', + 'component-properties', + 'snapshot', + 'highlight', + 'console', + 'performance', + ]); const resolved = await request( new URL(`/api/devtools/target?targetId=${encodeURIComponent(targetId)}`, debuggerServer.url).toString(), diff --git a/npm_modules/cli/src/debugger/server.ts b/npm_modules/cli/src/debugger/server.ts index 04ab45fd..ac6b0b33 100644 --- a/npm_modules/cli/src/debugger/server.ts +++ b/npm_modules/cli/src/debugger/server.ts @@ -1263,6 +1263,7 @@ function webPreviewTargetPayload(target: WebPreviewDebuggerTarget): DebuggerTarg attachable: true, capabilities: [ DebuggerTargetCapability.Components, + DebuggerTargetCapability.ComponentProperties, DebuggerTargetCapability.Snapshot, DebuggerTargetCapability.Highlight, DebuggerTargetCapability.Console, diff --git a/npm_modules/cli/src/debugger/targetRegistry.spec.ts b/npm_modules/cli/src/debugger/targetRegistry.spec.ts index c11439da..783537fa 100644 --- a/npm_modules/cli/src/debugger/targetRegistry.spec.ts +++ b/npm_modules/cli/src/debugger/targetRegistry.spec.ts @@ -50,6 +50,7 @@ function webPreviewTarget(): DebuggerTargetDescriptor { attachable: true, capabilities: [ DebuggerTargetCapability.Components, + DebuggerTargetCapability.ComponentProperties, DebuggerTargetCapability.Snapshot, DebuggerTargetCapability.Console, ], @@ -130,9 +131,11 @@ describe('debugger target registry', () => { expect(second.find(target => target.transport === DebuggerTargetTransport.ValdiDaemon)?.id).toBe(native.id); expect(replacement.id).not.toBe(native.id); expect(native.capabilities).toEqual([DebuggerTargetCapability.Components, DebuggerTargetCapability.Snapshot]); + expect(native.capabilities).not.toContain(DebuggerTargetCapability.ComponentProperties); expect(native.identityMode).toBe(DebuggerTargetIdentityMode.TargetId); expect(first).toContain( jasmine.objectContaining({ + capabilities: jasmine.arrayContaining([DebuggerTargetCapability.ComponentProperties]), id: 'owl:web-preview', identityMode: DebuggerTargetIdentityMode.InspectedPage, }), diff --git a/npm_modules/cli/src/debugger/targetRegistry.ts b/npm_modules/cli/src/debugger/targetRegistry.ts index ca6ffe9a..8f8be705 100644 --- a/npm_modules/cli/src/debugger/targetRegistry.ts +++ b/npm_modules/cli/src/debugger/targetRegistry.ts @@ -23,6 +23,7 @@ export enum DebuggerTargetTransport { /** Capability names are serialized so frontends can hide unsupported tools. */ export enum DebuggerTargetCapability { + ComponentProperties = 'component-properties', Components = 'components', Console = 'console', Highlight = 'highlight', diff --git a/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts b/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts index 48a021d4..315d26f1 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts @@ -26,6 +26,8 @@ export type ComponentDisposable = (() => void) | Unsubscribable; export interface RendererDebugVirtualNodeSnapshot { readonly children: readonly IRenderedVirtualNode[]; readonly component: IComponent | undefined; + /** Internal debugger input. Consumers must serialize a detached snapshot before exposing it. */ + readonly componentViewModel?: unknown; readonly element: IRenderedElement | undefined; readonly key: string; readonly parent: IRenderedVirtualNode | undefined; diff --git a/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts b/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts index 6f43a6c3..676ae790 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts @@ -319,7 +319,7 @@ class VirtualNodeBridge implements IRenderedVirtualNode { } } - return { + const snapshot: RendererDebugVirtualNodeSnapshot = { children, component: this.node.component?.instance, element: this.node.element === undefined ? undefined : getRenderedElementBridge(this.renderer, this.node.element), @@ -330,6 +330,11 @@ class VirtualNodeBridge implements IRenderedVirtualNode { : getVirtualNodeBridge(this.renderer, parent), traversedLinkCount, }; + Object.defineProperty(snapshot, 'componentViewModel', { + enumerable: false, + value: this.node.component?.viewModel, + }); + return snapshot; } get parentIndex(): number { diff --git a/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts b/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts index 24714673..0ff88040 100644 --- a/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts +++ b/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts @@ -2327,6 +2327,9 @@ describe('Renderer', () => { const headerContainerVirtualNode = componentDebugSnapshot.children[0]; const headerContainerDebugSnapshot = renderer.getDebugVirtualNodeSnapshot(headerContainerVirtualNode, 10, 20)!; + expect(componentDebugSnapshot.componentViewModel).toBe(componentDebugSnapshot.component?.viewModel); + expect(Object.getOwnPropertyDescriptor(componentDebugSnapshot, 'componentViewModel')?.enumerable).toBeFalse(); + expect(Object.keys(componentDebugSnapshot)).not.toContain('componentViewModel'); expect(headerContainerDebugSnapshot.children.map(child => child.element?.tag)).toEqual(['header']); expect(renderer.getDebugVirtualNodeSnapshot(headerContainerVirtualNode, 10, 2)).toBeUndefined(); }); diff --git a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts index f9895cd5..d6503ce8 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts @@ -37,6 +37,7 @@ export interface WebRendererDebugNodeSnapshot { elementId?: string; key: string; name: string; + properties?: Record; }; element?: { id: number; @@ -71,6 +72,7 @@ export interface WebRendererDebugComponentSnapshot extends WebRendererDebugNodeS elementId?: string; key: string; name: string; + properties?: Record; }; } @@ -329,10 +331,25 @@ export class ValdiWebRendererDelegate implements IRendererDelegate { return elementSnapshot; } const componentSnapshot: WebRendererDebugSnapshot = { tree: componentTree, viewport }; + if (JSON.stringify(componentSnapshot).length <= snapshotCharacterLimit) { + return componentSnapshot; + } + stripComponentProperties(componentTree); return JSON.stringify(componentSnapshot).length <= snapshotCharacterLimit ? componentSnapshot : elementSnapshot; } } +function stripComponentProperties(root: WebRendererDebugNodeSnapshot): void { + const pending = [root]; + while (pending.length > 0) { + const node = pending.pop()!; + if (node.component !== undefined) { + delete node.component.properties; + } + pending.push(...node.children); + } +} + function captureRenderedElementAttributes( element: IRenderedElement, budget: DebugSerializationBudget, diff --git a/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts b/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts index de67ccdd..04cfe27f 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts @@ -7,6 +7,7 @@ import type { WebRendererDebugElementSnapshot, WebRendererDebugNodeSnapshot, } from '../ValdiWebRendererDelegate'; +import { captureDebuggerPropertiesSnapshot, type DebuggerValueSnapshotLimits } from './DebuggerValueSnapshot'; const MAX_COMPONENT_HIERARCHY_CHILD_LINKS = 1_000; const MAX_COMPONENT_HIERARCHY_DEPTH = 64; @@ -16,6 +17,13 @@ const MAX_COMPONENT_HIERARCHY_TRAVERSAL_LINKS = 4_096; const MAX_COMPONENT_NAME_CHARACTERS = 256; const MAX_COMPONENT_KEY_CHARACTERS = 256; const MAX_COMPONENT_PROTOTYPE_DEPTH = 16; +const MAX_COMPONENT_PROPERTY_BYTES = 65_536; +const COMPONENT_PROPERTY_LIMITS: DebuggerValueSnapshotLimits = { + maximumDepth: 4, + maximumEntries: 50, + maximumPropertyNameCharacters: 256, + maximumStringBytes: 65_536, +}; interface IndexedElementTree { readonly childIdsByParentId: Map; @@ -30,6 +38,11 @@ interface CapturedHierarchyNode { interface CapturedVirtualNode extends RendererDebugVirtualNodeSnapshot { readonly node: IRenderedVirtualNode; + componentOutput?: WebRendererDebugComponentSnapshot; +} + +interface ComponentPropertyBudget { + remainingBytes: number; } interface VirtualTraversalFrame { @@ -80,6 +93,7 @@ export function captureComponentHierarchySnapshot( } const capturedNodes: CapturedVirtualNode[] = []; + const componentPropertyBudget: ComponentPropertyBudget = { remainingBytes: MAX_COMPONENT_PROPERTY_BYTES }; const componentIds = new Set(); const consumedChildCountByParentId = new Map(); const usedElementIds = new Set(); @@ -138,6 +152,7 @@ export function captureComponentHierarchySnapshot( frame.captured = { children: debugSnapshot.children, component: debugSnapshot.component, + componentViewModel: debugSnapshot.componentViewModel, element: debugSnapshot.element, key: debugSnapshot.key, node: frame.node, @@ -179,6 +194,7 @@ export function captureComponentHierarchySnapshot( indexedElements, componentIds, consumedChildCountByParentId, + componentPropertyBudget, usedElementIds, ); if (result === undefined) { @@ -214,6 +230,7 @@ function captureCompletedFrame( indexedElements: IndexedElementTree, componentIds: Set, consumedChildCountByParentId: Map, + componentPropertyBudget: ComponentPropertyBudget, usedElementIds: Set, ): CapturedHierarchyNode | undefined { const captured = frame.captured; @@ -256,6 +273,7 @@ function captureCompletedFrame( componentIds.add(componentId); const firstElementId = frame.capturedChildren.find(child => child.firstElementId !== undefined)?.firstElementId; const backingElement = firstElementId === undefined ? undefined : indexedElements.elementsById.get(firstElementId); + const properties = captureComponentProperties(captured.componentViewModel, componentPropertyBudget); const node: WebRendererDebugComponentSnapshot = { ...(backingElement === undefined ? {} : { bounds: backingElement.bounds }), children: frame.capturedChildren.map(child => child.node), @@ -263,10 +281,12 @@ function captureCompletedFrame( ...(firstElementId === undefined ? {} : { elementId: firstElementId }), key: captured.key, name: componentName, + ...(properties === undefined ? {} : { properties }), }, id: componentId, tag: componentName, }; + captured.componentOutput = node; return { ...(firstElementId === undefined ? {} : { firstElementId }), node, @@ -377,6 +397,9 @@ function isCapturedVirtualTopologyCurrent( ) { return false; } + if (current.componentViewModel !== captured.componentViewModel && captured.componentOutput !== undefined) { + delete captured.componentOutput.component.properties; + } remainingChildLinks -= children.length; remainingTraversalLinks -= current.traversedLinkCount; if (children.length !== captured.children.length) { @@ -391,6 +414,29 @@ function isCapturedVirtualTopologyCurrent( return true; } +function captureComponentProperties( + viewModel: unknown, + budget: ComponentPropertyBudget, +): Record | undefined { + if (budget.remainingBytes <= 0) { + return undefined; + } + const captured = captureDebuggerPropertiesSnapshot( + viewModel, + MAX_COMPONENT_PROPERTY_BYTES, + COMPONENT_PROPERTY_LIMITS, + ); + if (captured === undefined) { + return undefined; + } + if (captured.serializedBytes > budget.remainingBytes) { + budget.remainingBytes = 0; + return undefined; + } + budget.remainingBytes -= captured.serializedBytes; + return captured.value; +} + function readElementId(element: IRenderedElement): string | undefined { const id = element.id; return Number.isSafeInteger(id) && id >= 0 ? String(id) : undefined; diff --git a/src/valdi_modules/src/valdi/web_renderer/src/debug/DebuggerValueSnapshot.ts b/src/valdi_modules/src/valdi/web_renderer/src/debug/DebuggerValueSnapshot.ts new file mode 100644 index 00000000..85f1aa9f --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/debug/DebuggerValueSnapshot.ts @@ -0,0 +1,403 @@ +export interface DebuggerValueSnapshotLimits { + readonly maximumDepth: number; + readonly maximumEntries: number; + readonly maximumPropertyNameCharacters: number; + readonly maximumStringBytes: number; +} + +export interface DebuggerValueSnapshotCapture { + readonly serializedBytes: number; + readonly value: T; +} + +interface DebuggerValueSnapshotBudget { + remainingBytes: number; +} + +const DEBUG_ACCESSOR_OMISSION_MARKER = 'accessors or unsupported fields omitted'; +const DEBUG_CIRCULAR_MARKER = ''; +const DEBUG_EMPTY_ARRAY_ITEM_MARKER = ''; +const DEBUG_BIGINT_MARKER = ''; +const DEBUG_SYMBOL_MARKER = ''; +const DEBUG_TRUNCATION_MARKER = '... '; +const DEBUG_UNAVAILABLE_MARKER = ''; + +/** + * Produces a detached JSON value without reading property values through normal + * JavaScript property access. Only own, enumerable data descriptors are copied. + */ +export function captureDebuggerPropertiesSnapshot( + source: unknown, + maximumSerializedBytes: number, + limits: DebuggerValueSnapshotLimits, +): DebuggerValueSnapshotCapture> | undefined { + if (typeof source !== 'object' || source === null || !validMaximum(maximumSerializedBytes) || !validLimits(limits)) { + return undefined; + } + + const budget: DebuggerValueSnapshotBudget = { remainingBytes: maximumSerializedBytes }; + try { + if (Array.isArray(source)) { + return undefined; + } + const activePath = new Set([source]); + const value = captureObject(source, 0, activePath, budget, limits); + const serialized = JSON.stringify(value); + const serializedBytes = utf8ByteLength(serialized); + return serializedBytes <= maximumSerializedBytes ? { serializedBytes, value } : undefined; + } catch (_error) { + // Throwing or revoked Proxy reflection is an expected trust-boundary + // outcome. The caller treats undefined as a properties-only omission. + return undefined; + } +} + +function validMaximum(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} + +function validLimits(limits: DebuggerValueSnapshotLimits): boolean { + return ( + validMaximum(limits.maximumDepth) && + validMaximum(limits.maximumEntries) && + validMaximum(limits.maximumPropertyNameCharacters) && + validMaximum(limits.maximumStringBytes) + ); +} + +function captureValue( + value: unknown, + depth: number, + activePath: Set, + budget: DebuggerValueSnapshotBudget, + limits: DebuggerValueSnapshotLimits, +): unknown { + if (depth >= limits.maximumDepth) { + return captureString(DEBUG_TRUNCATION_MARKER, budget, limits.maximumStringBytes); + } + if (value === undefined) { + consumeBudget(budget, 4); + return null; + } + if (value === null || typeof value === 'boolean') { + consumeBudget(budget, value === null ? 4 : value ? 4 : 5); + return value; + } + if (typeof value === 'number') { + const serialized = JSON.stringify(value) ?? 'null'; + consumeBudget(budget, serialized.length); + return value; + } + if (typeof value === 'string') { + return captureString(value, budget, limits.maximumStringBytes); + } + if (typeof value === 'function') { + return captureString('[function]', budget, limits.maximumStringBytes); + } + if (typeof value === 'bigint') { + return captureString(DEBUG_BIGINT_MARKER, budget, limits.maximumStringBytes); + } + if (typeof value === 'symbol') { + return captureString(DEBUG_SYMBOL_MARKER, budget, limits.maximumStringBytes); + } + if (typeof value !== 'object') { + return captureString(DEBUG_UNAVAILABLE_MARKER, budget, limits.maximumStringBytes); + } + if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(value)) { + return captureString('', budget, limits.maximumStringBytes); + } + if (activePath.has(value)) { + return captureString(DEBUG_CIRCULAR_MARKER, budget, limits.maximumStringBytes); + } + + activePath.add(value); + try { + return Array.isArray(value) + ? captureArray(value, depth, activePath, budget, limits) + : captureObject(value, depth, activePath, budget, limits); + } catch (_error) { + // A nested throwing Proxy is represented without discarding safe sibling + // fields; its enclosing top-level capture remains detached and bounded. + return captureString(DEBUG_UNAVAILABLE_MARKER, budget, limits.maximumStringBytes); + } finally { + activePath.delete(value); + } +} + +function captureArray( + value: unknown[], + depth: number, + activePath: Set, + budget: DebuggerValueSnapshotBudget, + limits: DebuggerValueSnapshotLimits, +): unknown[] { + const output: unknown[] = []; + if (!tryConsumeBudget(budget, 2)) { + return output; + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + const length = + typeof lengthDescriptor?.value === 'number' && Number.isSafeInteger(lengthDescriptor.value) + ? Math.max(0, lengthDescriptor.value) + : 0; + const itemCount = Math.min(length, limits.maximumEntries); + let inspectedItemCount = 0; + for (; inspectedItemCount < itemCount; inspectedItemCount++) { + if (!tryConsumeArrayItemPrefix(output, budget, 2)) { + break; + } + const descriptor = Object.getOwnPropertyDescriptor(value, String(inspectedItemCount)); + if ( + descriptor === undefined || + !descriptor.enumerable || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + output.push(captureString(DEBUG_EMPTY_ARRAY_ITEM_MARKER, budget, limits.maximumStringBytes)); + continue; + } + output.push(captureValue(descriptor.value, depth + 1, activePath, budget, limits)); + } + if ( + length > inspectedItemCount && + output.length < limits.maximumEntries && + tryConsumeArrayItemPrefix(output, budget, 2) + ) { + output.push(captureString(`${length - inspectedItemCount} more items`, budget, limits.maximumStringBytes)); + } + return output; +} + +function captureObject( + value: object, + depth: number, + activePath: Set, + budget: DebuggerValueSnapshotBudget, + limits: DebuggerValueSnapshotLimits, +): Record { + const output = Object.create(null) as Record; + if (!tryConsumeBudget(budget, 2)) { + return output; + } + let inspectedEntryCount = 0; + let omitted = false; + // JavaScript has no resumable own-key iterator. This may materialize a + // Proxy's complete own-key result, but it never traverses the prototype. + // Property values are still read only from data descriptors. + const propertyNames = Object.getOwnPropertyNames(value); + for (const propertyName of propertyNames) { + const descriptor = Object.getOwnPropertyDescriptor(value, propertyName); + if (descriptor === undefined || !descriptor.enumerable) { + continue; + } + if (inspectedEntryCount >= limits.maximumEntries) { + omitted = true; + break; + } + inspectedEntryCount++; + if ( + propertyName.length > limits.maximumPropertyNameCharacters || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + omitted = true; + continue; + } + if (!tryConsumePropertyPrefix(output, propertyName, budget, 2)) { + omitted = true; + break; + } + setDataProperty(output, propertyName, captureValue(descriptor.value, depth + 1, activePath, budget, limits)); + } + if (omitted) { + addTruncationProperty(output, DEBUG_ACCESSOR_OMISSION_MARKER, budget, limits); + } + return output; +} + +function addTruncationProperty( + target: Record, + message: string, + budget: DebuggerValueSnapshotBudget, + limits: DebuggerValueSnapshotLimits, +): void { + if ( + Object.keys(target).length >= limits.maximumEntries || + Object.prototype.hasOwnProperty.call(target, '__truncated__') || + !tryConsumePropertyPrefix(target, '__truncated__', budget, 2) + ) { + return; + } + setDataProperty(target, '__truncated__', captureString(message, budget, limits.maximumStringBytes)); +} + +function setDataProperty(target: Record, propertyName: string, value: unknown): void { + Object.defineProperty(target, propertyName, { + configurable: true, + enumerable: true, + value, + writable: true, + }); +} + +function captureString(value: string, budget: DebuggerValueSnapshotBudget, maximumStringBytes: number): string { + const availableContentBytes = Math.max(0, budget.remainingBytes - 2); + const completePrefix = boundedStringPrefix(value, maximumStringBytes, availableContentBytes); + if (completePrefix.end === value.length) { + consumeBudget(budget, completePrefix.jsonBytes + 2); + return value; + } + + const markerRawBytes = utf8ByteLength(DEBUG_TRUNCATION_MARKER); + const markerJsonBytes = jsonStringUtf8ByteLength(DEBUG_TRUNCATION_MARKER) - 2; + const markerFits = markerRawBytes <= maximumStringBytes && markerJsonBytes <= availableContentBytes; + const marker = markerFits ? DEBUG_TRUNCATION_MARKER : ''; + const truncatedPrefix = boundedStringPrefix( + value, + maximumStringBytes - (markerFits ? markerRawBytes : 0), + availableContentBytes - (markerFits ? markerJsonBytes : 0), + ); + const captured = `${value.slice(0, truncatedPrefix.end)}${marker}`; + consumeBudget(budget, truncatedPrefix.jsonBytes + (markerFits ? markerJsonBytes : 0) + 2); + return captured; +} + +interface BoundedStringPrefix { + readonly end: number; + readonly jsonBytes: number; +} + +function boundedStringPrefix(value: string, maximumRawBytes: number, maximumJsonBytes: number): BoundedStringPrefix { + let rawBytes = 0; + let jsonBytes = 0; + let end = 0; + while (end < value.length) { + const characterCode = value.charCodeAt(end); + const nextCharacterCode = value.charCodeAt(end + 1); + const isSurrogatePair = + characterCode >= 0xd800 && characterCode <= 0xdbff && nextCharacterCode >= 0xdc00 && nextCharacterCode <= 0xdfff; + const nextEnd = end + (isSurrogatePair ? 2 : 1); + const characterRawBytes = characterCode < 0x80 ? 1 : characterCode < 0x800 ? 2 : isSurrogatePair ? 4 : 3; + let characterJsonBytes: number; + if ( + characterCode === 0x22 || + characterCode === 0x5c || + characterCode === 0x08 || + characterCode === 0x09 || + characterCode === 0x0a || + characterCode === 0x0c || + characterCode === 0x0d + ) { + characterJsonBytes = 2; + } else if (characterCode < 0x20 || (!isSurrogatePair && characterCode >= 0xd800 && characterCode <= 0xdfff)) { + characterJsonBytes = 6; + } else { + characterJsonBytes = characterRawBytes; + } + if (rawBytes + characterRawBytes > maximumRawBytes || jsonBytes + characterJsonBytes > maximumJsonBytes) { + break; + } + rawBytes += characterRawBytes; + jsonBytes += characterJsonBytes; + end = nextEnd; + } + return { end, jsonBytes }; +} + +function tryConsumePropertyPrefix( + target: object, + propertyName: string, + budget: DebuggerValueSnapshotBudget, + minimumValueBytes: number, +): boolean { + const separatorBytes = Object.keys(target).length === 0 ? 0 : 1; + const prefixBytes = separatorBytes + jsonStringUtf8ByteLength(propertyName) + 1; + if (prefixBytes + minimumValueBytes > budget.remainingBytes) { + return false; + } + consumeBudget(budget, prefixBytes); + return true; +} + +function tryConsumeArrayItemPrefix( + target: unknown[], + budget: DebuggerValueSnapshotBudget, + minimumValueBytes: number, +): boolean { + const prefixBytes = target.length === 0 ? 0 : 1; + if (prefixBytes + minimumValueBytes > budget.remainingBytes) { + return false; + } + consumeBudget(budget, prefixBytes); + return true; +} + +function tryConsumeBudget(budget: DebuggerValueSnapshotBudget, byteCount: number): boolean { + if (byteCount > budget.remainingBytes) { + return false; + } + consumeBudget(budget, byteCount); + return true; +} + +function consumeBudget(budget: DebuggerValueSnapshotBudget, byteCount: number): void { + budget.remainingBytes = Math.max(0, budget.remainingBytes - Math.max(0, byteCount)); +} + +function jsonStringUtf8ByteLength(value: string): number { + let byteCount = 2; + for (let index = 0; index < value.length; index++) { + const characterCode = value.charCodeAt(index); + if ( + characterCode === 0x22 || + characterCode === 0x5c || + characterCode === 0x08 || + characterCode === 0x09 || + characterCode === 0x0a || + characterCode === 0x0c || + characterCode === 0x0d + ) { + byteCount += 2; + } else if (characterCode < 0x20) { + byteCount += 6; + } else if (characterCode < 0x80) { + byteCount += 1; + } else if (characterCode < 0x800) { + byteCount += 2; + } else if (characterCode >= 0xd800 && characterCode <= 0xdbff) { + const nextCharacterCode = value.charCodeAt(index + 1); + if (nextCharacterCode >= 0xdc00 && nextCharacterCode <= 0xdfff) { + byteCount += 4; + index++; + } else { + byteCount += 6; + } + } else if (characterCode >= 0xdc00 && characterCode <= 0xdfff) { + byteCount += 6; + } else { + byteCount += 3; + } + } + return byteCount; +} + +function utf8ByteLength(value: string): number { + let byteCount = 0; + for (let index = 0; index < value.length; index++) { + const characterCode = value.charCodeAt(index); + if (characterCode < 0x80) { + byteCount += 1; + } else if (characterCode < 0x800) { + byteCount += 2; + } else if (characterCode >= 0xd800 && characterCode <= 0xdbff) { + const nextCharacterCode = value.charCodeAt(index + 1); + if (nextCharacterCode >= 0xdc00 && nextCharacterCode <= 0xdfff) { + byteCount += 4; + index++; + } else { + byteCount += 3; + } + } else { + byteCount += 3; + } + } + return byteCount; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/test/DebuggerValueSnapshot.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/DebuggerValueSnapshot.spec.ts new file mode 100644 index 00000000..311c0ad0 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/DebuggerValueSnapshot.spec.ts @@ -0,0 +1,131 @@ +import 'jasmine/src/jasmine'; +import { + captureDebuggerPropertiesSnapshot, + type DebuggerValueSnapshotLimits, +} from '../src/debug/DebuggerValueSnapshot'; + +const LIMITS: DebuggerValueSnapshotLimits = { + maximumDepth: 4, + maximumEntries: 50, + maximumPropertyNameCharacters: 256, + maximumStringBytes: 65_536, +}; + +describe('DebuggerValueSnapshot', () => { + it('captures only own enumerable data descriptors without invoking accessors', () => { + let getterCalls = 0; + const inherited = { inherited: 'hidden' }; + const source = Object.create(inherited) as Record; + source.visible = { nested: true }; + source[Symbol('symbol-property')] = 'hidden'; + Object.defineProperty(source, 'secret', { + enumerable: true, + get: () => { + getterCalls++; + return 'must not be read'; + }, + }); + + const captured = captureDebuggerPropertiesSnapshot(source, 65_536, LIMITS); + + expect(getterCalls).toBe(0); + expect(JSON.stringify(captured?.value.visible)).toBe('{"nested":true}'); + expect(Object.prototype.hasOwnProperty.call(captured?.value, 'secret')).toBeFalse(); + expect(Object.prototype.hasOwnProperty.call(captured?.value, 'inherited')).toBeFalse(); + expect(Object.getOwnPropertySymbols(captured?.value ?? {})).toEqual([]); + }); + + it('bounds keys and entries', () => { + const source: Record = {}; + source['k'.repeat(257)] = 'hidden'; + for (let index = 0; index < 60; index++) { + source[`property-${index}`] = index; + } + const captured = captureDebuggerPropertiesSnapshot(source, 65_536, LIMITS); + + expect(captured).toBeDefined(); + expect(Object.keys(captured!.value).length).toBeLessThanOrEqual(50); + expect(Object.prototype.hasOwnProperty.call(captured!.value, 'k'.repeat(257))).toBeFalse(); + }); + + it('limits every nested container to 50 entries including truncation markers', () => { + const nestedObject: Record = {}; + for (let index = 0; index < 60; index++) { + nestedObject[`property-${index}`] = index; + } + const captured = captureDebuggerPropertiesSnapshot( + { nestedArray: Array.from({ length: 60 }, (_value, index) => index), nestedObject }, + 65_536, + LIMITS, + ); + + expect(captured).toBeDefined(); + expect((captured!.value.nestedArray as unknown[]).length).toBeLessThanOrEqual(50); + expect(Object.keys(captured!.value.nestedObject as Record).length).toBeLessThanOrEqual(50); + }); + + it('bounds nested depth, strings, and the complete UTF-8 payload', () => { + const captured = captureDebuggerPropertiesSnapshot( + { + nested: { one: { two: { three: { four: 'hidden' } } } }, + unicode: '😀'.repeat(65_536), + }, + 65_536, + LIMITS, + ); + const serialized = JSON.stringify(captured?.value); + + expect(captured).toBeDefined(); + expect(captured!.serializedBytes).toBeLessThanOrEqual(65_536); + expect(captured!.serializedBytes).toBeGreaterThanOrEqual(serialized.length); + expect(JSON.stringify(captured!.value.nested)).toContain('... '); + expect(String(captured!.value.unicode)).toContain('... '); + }); + + it('uses bounded markers for bigint and symbol values', () => { + const bigintFactory = Reflect.get(globalThis, 'BigInt') as (value: number) => unknown; + const captured = captureDebuggerPropertiesSnapshot( + { bigint: bigintFactory(1), symbol: Symbol('description is intentionally not copied') }, + 65_536, + LIMITS, + ); + + expect(captured?.value.bigint).toBe(''); + expect(captured?.value.symbol).toBe(''); + }); + + it('fails closed when a Proxy refuses or revokes descriptor inspection', () => { + const source = new Proxy( + { visible: 'value' }, + { + ownKeys: () => { + throw new Error('unavailable'); + }, + }, + ); + + expect(captureDebuggerPropertiesSnapshot(source, 65_536, LIMITS)).toBeUndefined(); + + const revocable = Proxy.revocable({ visible: 'value' }, {}); + revocable.revoke(); + expect(captureDebuggerPropertiesSnapshot(revocable.proxy, 65_536, LIMITS)).toBeUndefined(); + }); + + it('does not traverse Proxy prototypes while capturing own fields', () => { + let prototypeTrapCalls = 0; + const source = new Proxy( + { visible: 'value' }, + { + getPrototypeOf: () => { + prototypeTrapCalls++; + throw new Error('prototype traversal is forbidden'); + }, + }, + ); + + const captured = captureDebuggerPropertiesSnapshot(source, 65_536, LIMITS); + + expect(prototypeTrapCalls).toBe(0); + expect(captured?.value.visible).toBe('value'); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts index f02b451f..38495db0 100644 --- a/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts +++ b/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts @@ -196,6 +196,7 @@ function makeRenderer(elements: IRenderedElement[]): IRenderer { interface MutableVirtualNode { children: MutableVirtualNode[]; component?: IComponent; + componentViewModel?: unknown; element?: IRenderedElement; key: string; parent?: MutableVirtualNode; @@ -235,6 +236,7 @@ function makeHierarchyRenderer( return { children: children.slice(), component: mutableNode.component, + componentViewModel: mutableNode.componentViewModel, element: mutableNode.element, key: mutableNode.key, parent: mutableNode.parent as unknown as IRenderedVirtualNode | undefined, @@ -465,7 +467,7 @@ describe('ValdiWebRendererDelegate debugger adapter', () => { expect(second.tree?.children[0].element?.attributes.value).toBe('second'); }); - it('does not inspect component fields or invoke an instance constructor accessor', () => { + it('captures the renderer-owned ViewModel without inspecting component instance accessors', () => { class SafeComponent {} const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); @@ -487,7 +489,16 @@ describe('ValdiWebRendererDelegate debugger adapter', () => { throw new Error('Component view models belong to a later debugger layer.'); }, }); + const viewModel: Record = { visible: 'safe' }; + Object.defineProperty(viewModel, 'secret', { + enumerable: true, + get: () => { + getterCalls++; + throw new Error('ViewModel accessors must not be invoked.'); + }, + }); const component = makeVirtualNode('safe', { component: componentInstance }); + component.componentViewModel = viewModel; const elementNode = makeVirtualNode('layout', { element }); setVirtualChildren(component, [elementNode]); @@ -498,6 +509,143 @@ describe('ValdiWebRendererDelegate debugger adapter', () => { expect(getterCalls).toBe(0); expect(snapshot.tree?.component?.name).toBe('SafeComponent'); + expect(snapshot.tree?.component?.properties?.visible).toBe('safe'); + expect(Object.prototype.hasOwnProperty.call(snapshot.tree?.component?.properties ?? {}, 'secret')).toBeFalse(); + }); + + it('shares the component property budget without discarding over-budget component hierarchy', () => { + class ParentBudgetComponent {} + class ChildBudgetComponent {} + + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const element = makeRenderedElement(1, 'layout', {}); + const parentComponent = makeVirtualNode('parent-budget', { + component: new ParentBudgetComponent() as unknown as IComponent, + }); + parentComponent.componentViewModel = { payload: 'p'.repeat(40_000) }; + const childComponent = makeVirtualNode('child-budget', { + component: new ChildBudgetComponent() as unknown as IComponent, + }); + childComponent.componentViewModel = { payload: 'c'.repeat(40_000) }; + const elementNode = makeVirtualNode('layout', { element }); + setVirtualChildren(childComponent, [elementNode]); + setVirtualChildren(parentComponent, [childComponent]); + + const snapshot = delegate.getDebugSnapshot( + makeHierarchyRenderer([element], parentComponent), + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + ); + const childSnapshot = snapshot.tree?.children[0]; + + expect(snapshot.tree?.component?.name).toBe('ParentBudgetComponent'); + expect(snapshot.tree?.component?.properties).toBeUndefined(); + expect(childSnapshot?.component?.name).toBe('ChildBudgetComponent'); + expect(String(childSnapshot?.component?.properties?.payload).length).toBe(40_000); + expect(JSON.stringify(childSnapshot?.component?.properties).length).toBeLessThanOrEqual(65_536); + expect(childSnapshot?.children[0].id).toBe('1'); + }); + + it('drops stale properties but keeps hierarchy when the ViewModel identity changes during capture', () => { + class ReplacedViewModelComponent {} + + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const element = makeRenderedElement(1, 'layout', {}); + const component = makeVirtualNode('replaced-view-model', { + component: new ReplacedViewModelComponent() as unknown as IComponent, + }); + const elementNode = makeVirtualNode('layout', { element }); + setVirtualChildren(component, [elementNode]); + const baseRenderer = makeHierarchyRenderer([element], component); + const readSnapshot = baseRenderer.getDebugVirtualNodeSnapshot!; + const initialViewModel = { value: 'stale' }; + const replacementViewModel = { value: 'fresh' }; + let componentSnapshotReads = 0; + const renderer = Object.assign(baseRenderer, { + getDebugVirtualNodeSnapshot: ( + node: IRenderedVirtualNode, + maximumChildLinks: number, + maximumTraversalLinks: number, + ) => { + const snapshot = readSnapshot.call(baseRenderer, node, maximumChildLinks, maximumTraversalLinks); + if (snapshot === undefined || node !== (component as unknown as IRenderedVirtualNode)) { + return snapshot; + } + componentSnapshotReads++; + return { + ...snapshot, + componentViewModel: componentSnapshotReads === 1 ? initialViewModel : replacementViewModel, + }; + }, + }); + + const snapshot = delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + + expect(componentSnapshotReads).toBe(2); + expect(snapshot.tree?.component?.name).toBe('ReplacedViewModelComponent'); + expect(snapshot.tree?.component?.properties).toBeUndefined(); + expect(snapshot.tree?.children[0].id).toBe('1'); + }); + + it('strips component properties before the complete hierarchy envelope overflows', () => { + class PropertyHeavyComponent {} + + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const element = makeRenderedElement(1, 'layout', { + first: 'a'.repeat(50_000), + fourth: 'd'.repeat(50_000), + second: 'b'.repeat(50_000), + third: 'c'.repeat(50_000), + }); + const component = makeVirtualNode('property-heavy', { + component: new PropertyHeavyComponent() as unknown as IComponent, + }); + component.componentViewModel = { payload: 'x'.repeat(65_536) }; + const elementNode = makeVirtualNode('layout', { element }); + setVirtualChildren(component, [elementNode]); + + const snapshot = delegate.getDebugSnapshot( + makeHierarchyRenderer([element], component), + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + ); + + expect(snapshot.tree?.id).toBe('component:[null,"property-heavy"]'); + expect(snapshot.tree?.component?.name).toBe('PropertyHeavyComponent'); + expect(snapshot.tree?.component?.properties).toBeUndefined(); + expect(snapshot.tree?.children[0].id).toBe('1'); + expect(JSON.stringify(snapshot).length).toBeLessThanOrEqual(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + }); + + it('omits properties from a revoked Proxy without discarding the hierarchy', () => { + class ProxyBackedComponent {} + + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const element = makeRenderedElement(1, 'layout', {}); + const component = makeVirtualNode('proxy-backed', { + component: new ProxyBackedComponent() as unknown as IComponent, + }); + const revocableViewModel = Proxy.revocable({ visible: 'value' }, {}); + component.componentViewModel = revocableViewModel.proxy; + revocableViewModel.revoke(); + const elementNode = makeVirtualNode('layout', { element }); + setVirtualChildren(component, [elementNode]); + + const snapshot = delegate.getDebugSnapshot( + makeHierarchyRenderer([element], component), + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + ); + + expect(snapshot.tree?.id).toBe('component:[null,"proxy-backed"]'); + expect(snapshot.tree?.component?.name).toBe('ProxyBackedComponent'); + expect(snapshot.tree?.component?.properties).toBeUndefined(); + expect(snapshot.tree?.children[0].id).toBe('1'); }); it('falls back atomically for shared, cyclic, partial, and over-deep virtual trees', () => {