Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions npm_modules/cli/debugger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions npm_modules/cli/debugger/devtools-panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
31 changes: 26 additions & 5 deletions npm_modules/cli/debugger/devtools-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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 `
<section class="component-properties" aria-label="Valdi props">
<div class="rule-header">Valdi props <span class="rule-origin">read only</span></div>
<div class="property-list">${propertyRows(node.component.properties, { css: false })}</div>
</section>
`;
}

function renderStyles(node) {
const attributes = nodeAttributes(node);
const domStyle = node.element?.dom?.attributes?.style
Expand Down Expand Up @@ -1104,23 +1124,24 @@ function renderInspector() {
}

const renderedNode = inspectedNode(node);
const componentProperties = renderComponentProperties(node);
if (node.component && renderedNode === node) {
elements.inspector.innerHTML = `<div class="rule-header">Valdi component <span class="rule-origin">${escapeHtml(node.tag)}</span></div>${propertyRows(node.component, { css: false })}<div class="empty-state">This component does not currently render a backing element.</div>`;
elements.inspector.innerHTML = `<div class="rule-header">Valdi component <span class="rule-origin">${escapeHtml(node.tag)}</span></div>${propertyRows(componentMetadata(node), { css: false })}${componentProperties}<div class="empty-state">This component does not currently render a backing element.</div>`;
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
? `<div class="rule-header">Valdi component <span class="rule-origin">${escapeHtml(node.tag)}</span></div>${propertyRows(node.component, { css: false })}`
? `<div class="rule-header">Valdi component <span class="rule-origin">${escapeHtml(node.tag)}</span></div>${propertyRows(componentMetadata(node), { css: false })}`
: '';
elements.inspector.innerHTML = `${componentDetails}<div class="rule-header">Rendered &lt;${escapeHtml(valdiDebuggerTreeModel.formatValue(renderedNode.element?.dom?.tagName || 'div', 0))}&gt;</div>${propertyRows(renderedNode.element?.dom?.attributes, { css: false })}${textContent ? `<div class="rule-header">Text content</div><pre class="json-view">${escapeHtml(textContent)}</pre>` : ''}`;
elements.inspector.innerHTML = `${componentDetails}${componentProperties}<div class="rule-header">Rendered &lt;${escapeHtml(valdiDebuggerTreeModel.formatValue(renderedNode.element?.dom?.tagName || 'div', 0))}&gt;</div>${propertyRows(renderedNode.element?.dom?.attributes, { css: false })}${textContent ? `<div class="rule-header">Text content</div><pre class="json-view">${escapeHtml(textContent)}</pre>` : ''}`;
}
}

Expand Down
80 changes: 75 additions & 5 deletions npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> };
element?: {
attributes: Record<string, unknown>;
dom: { attributes: Record<string, string>; tagName: string; textContent?: string };
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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: '<img src=x onerror=alert(1)>' },
},
id: 'component:["7","nested"]',
tag: 'NestedExampleComponent',
},
Expand Down Expand Up @@ -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,
Expand All @@ -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;
});
Expand Down Expand Up @@ -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('&lt;img src=x onerror=alert(1)&gt;');
expect(panel.inspectorContent.innerHTML).not.toContain('<img src=x');

panel.state.target = { capabilities: [], id: 'native-target', sessionId: 'native-session' };
panel.renderInspector();

expect(panel.inspectorContent.innerHTML).not.toContain('aria-label="Valdi props"');
});

it('distinguishes omitted properties from a captured empty ViewModel', () => {
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<Record<string, unknown>> }) => 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() };
Expand Down
11 changes: 9 additions & 2 deletions npm_modules/cli/src/debugger/server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions npm_modules/cli/src/debugger/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,7 @@ function webPreviewTargetPayload(target: WebPreviewDebuggerTarget): DebuggerTarg
attachable: true,
capabilities: [
DebuggerTargetCapability.Components,
DebuggerTargetCapability.ComponentProperties,
DebuggerTargetCapability.Snapshot,
DebuggerTargetCapability.Highlight,
DebuggerTargetCapability.Console,
Expand Down
3 changes: 3 additions & 0 deletions npm_modules/cli/src/debugger/targetRegistry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function webPreviewTarget(): DebuggerTargetDescriptor {
attachable: true,
capabilities: [
DebuggerTargetCapability.Components,
DebuggerTargetCapability.ComponentProperties,
DebuggerTargetCapability.Snapshot,
DebuggerTargetCapability.Console,
],
Expand Down Expand Up @@ -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,
}),
Expand Down
1 change: 1 addition & 0 deletions npm_modules/cli/src/debugger/targetRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface WebRendererDebugNodeSnapshot {
elementId?: string;
key: string;
name: string;
properties?: Record<string, unknown>;
};
element?: {
id: number;
Expand Down Expand Up @@ -71,6 +72,7 @@ export interface WebRendererDebugComponentSnapshot extends WebRendererDebugNodeS
elementId?: string;
key: string;
name: string;
properties?: Record<string, unknown>;
};
}

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading