[contextId]`\
Queries or controls a running debug `valdi_application` through the default,
diff --git a/npm_modules/cli/README.md b/npm_modules/cli/README.md
index 4d5cf964b..a04a476fd 100644
--- a/npm_modules/cli/README.md
+++ b/npm_modules/cli/README.md
@@ -89,6 +89,7 @@ For complete documentation, see:
- Restricts the server to loopback addresses because debugger snapshots can contain application data
- Prefers port `8765` and automatically selects the next available port so multiple local sessions can run at once
- Supports `--json` for automation-friendly startup output
+- Supports exact-page Owl/Chromium attachment with `--web-preview-url` and `--chromium-debugging-port`; this emits a temporary first-party DevTools extension directory and an explicitly opted-in preview URL
**`valdi skills`** - AI assistant skills
- Installs Valdi context files into Claude Code, Cursor, or GitHub Copilot so AI tools generate correct Valdi code instead of React patterns
diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md
index c1889fac6..f741e362e 100644
--- a/npm_modules/cli/debugger/README.md
+++ b/npm_modules/cli/debugger/README.md
@@ -10,7 +10,8 @@ the CLI package.
- `debugger.css`: themes, layout, controls, preview, inspector, and responsive styles.
- `debugger-state.js`: shared state, DOM references, constants, and action parameter helpers.
- `debugger-api.js`: fetch helpers, action stream, and development reload stream.
-- `debugger-model.js`: snapshot normalization, tree traversal, bounds, issues, and selection helpers.
+- `debugger-tree-model.js`: transport-neutral hierarchy identity, traversal, lookup, and path helpers shared by both frontends.
+- `debugger-model.js`: snapshot normalization, bounds, issues, and standalone selection helpers.
- `debugger-preview-html.js`: inert HTML projection of the hot-reloaded snapshot tree.
- `debugger-render.js`: header, target list, tree, preview overlay, inspector, and export rendering.
- `debugger-runtime.js`: target discovery, snapshots, runtime log streaming, heap, and copy/export helpers.
@@ -18,6 +19,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 and Console 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
@@ -35,6 +37,8 @@ Important routes:
- `/api/runtime-logs` and `/api/runtime-logs/stream`: read and stream target logs.
- `/api/debugger/state`, `/api/debugger/events`, and `/api/debugger/actions`: keep the browser UI and external agents in sync.
- `/api/performance/profile/*`: list Hermes contexts and capture CPU profiles.
+- `/api/devtools/target`: matches the inspected Chromium page to the exact configured preview origin and path.
+- `/api/devtools/snapshot`, `/api/devtools/highlight`, and `/api/devtools/evaluate`: proxy the explicit web debugger bridge contract through loopback CDP.
Renderer tracing is intentionally not part of this foundation. It requires the
separate runtime and native renderer-instrumentation stack; land that stack
@@ -43,8 +47,10 @@ profiling uses the existing inspector transport and has no such prerequisite.
Target input forwarding and data/network provider tabs should likewise land
with their runtime-side contracts and end-to-end tests rather than as inactive
browser-only surfaces.
-Web-renderer inspection should land together with its first-party bridge rather
-than expose an inert preview flag from this foundation.
+Web-renderer inspection depends on the target page explicitly exposing
+`window.__VALDI_WEB_DEBUGGER__` with `getSnapshot()`, `highlightNode()`, and
+`clearHighlight()`. The renderer-side adapter is intentionally outside this
+CLI/DevTools core.
Detailed debugger snapshots explicitly opt in to component ViewModel and state
serialization. That data can be sensitive, is bounded by a per-field and
@@ -53,6 +59,11 @@ tree` requests. Auto-refresh starts disabled so serialization remains a
deliberate local debugging action. The server rejects non-loopback Host,
Origin, and cross-site browser API requests.
+The normal debugger document and every other static asset use
+`frame-ancestors 'none'` plus `X-Frame-Options: DENY`. Only
+`/devtools-panel.html` permits a Chromium extension ancestor; executable
+DevTools routes additionally require same-origin JSON requests.
+
## Development Loop
For an installed CLI:
@@ -69,6 +80,20 @@ npm run build
node dist/index.js debugger --host 127.0.0.1 --port 8765
```
+For a manually launched Owl/Chromium web preview:
+
+```bash
+node dist/index.js debugger \
+ --web-preview-url http://127.0.0.1:8080/index.html \
+ --chromium-debugging-port 9222
+```
+
+Start Owl/Chromium with the printed `--remote-debugging-port` and
+`--load-extension` values, then open the exact opted-in preview URL printed by
+the command. Target matching removes only the injected `valdiDebugger` and
+`valdiDevTools` parameters, then requires the same origin, pathname, and
+remaining query parameters.
+
The synthetic native-tree preview never auto-loads projected HTTP(S) image,
video, CSS background, or WebView resources. Only `data:` and `blob:` media are
assigned; WebView contents are represented by an inert placeholder.
diff --git a/npm_modules/cli/debugger/debugger-model.js b/npm_modules/cli/debugger/debugger-model.js
index a939d9a72..02c5d2a2c 100644
--- a/npm_modules/cli/debugger/debugger-model.js
+++ b/npm_modules/cli/debugger/debugger-model.js
@@ -48,6 +48,7 @@ function markSelectedTarget(targets, selectedTarget) {
}
function decorateSnapshot(snapshot) {
+ snapshot.tree = valdiDebuggerTreeModel.restoreTree(snapshot.tree);
if (!snapshot.tree) {
snapshot.target = snapshot.target || { ...emptyTarget };
snapshot.targets = snapshot.targets || [];
@@ -66,30 +67,46 @@ function decorateSnapshot(snapshot) {
}
function decorateNode(node, path = '0') {
- if (node.element && node.element.frame) {
- node.bounds = normalizeBounds(node.element.frame);
- }
-
- if (!node.id) {
- if (node.element && node.element.id !== undefined) {
- node.id = String(node.element.id);
- } else if (node.key !== undefined) {
- node.id = `${node.tag}:${node.key}:${path}`;
- } else {
- node.id = `${node.tag}:${path}`;
- }
- }
-
- (node.children || []).forEach((child, index) => decorateNode(child, `${path}.${index}`));
+ const records = [];
+ const childrenByNode = new Map();
+ const paths = new Map();
+ valdiDebuggerTreeModel.walk(
+ node,
+ (current, ancestors, _depth, sourceChildIndex) => {
+ const parent = ancestors.at(-1);
+ const parentPath = parent ? paths.get(parent) : null;
+ const childIndex = parent ? Math.max(0, sourceChildIndex ?? 0) : 0;
+ const currentPath = parentPath === null || parentPath === undefined ? path : `${parentPath}.${childIndex}`;
+ paths.set(current, currentPath);
+ if (parent) {
+ const children = childrenByNode.get(parent) || [];
+ children.push(current);
+ childrenByNode.set(parent, children);
+ }
+ childrenByNode.set(current, childrenByNode.get(current) || []);
- if (!node.bounds && node.children && node.children.length) {
- const childBounds = node.children.map(child => child.bounds).filter(Boolean);
- if (childBounds.length) {
- const minX = Math.min(...childBounds.map(bounds => bounds.x));
- const minY = Math.min(...childBounds.map(bounds => bounds.y));
- const maxX = Math.max(...childBounds.map(bounds => bounds.x + bounds.width));
- const maxY = Math.max(...childBounds.map(bounds => bounds.y + bounds.height));
- node.bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
+ if (current.element && current.element.frame) {
+ current.bounds = normalizeBounds(current.element.frame);
+ }
+ if (!current.id) {
+ if (current.element && current.element.id !== undefined) {
+ current.id = String(current.element.id);
+ } else if (current.key !== undefined) {
+ current.id = `${current.tag}:${current.key}:${currentPath}`;
+ } else {
+ current.id = `${current.tag}:${currentPath}`;
+ }
+ }
+ records.push(current);
+ },
+ [],
+ 0,
+ );
+ for (let index = records.length - 1; index >= 0; index -= 1) {
+ const current = records[index];
+ if (!current.bounds) {
+ const childBounds = (childrenByNode.get(current) || []).map(child => child.bounds).filter(Boolean);
+ if (childBounds.length) current.bounds = unionBounds(childBounds);
}
}
}
@@ -104,10 +121,7 @@ function normalizeBounds(bounds) {
}
function getNodeId(node) {
- if (node.id !== undefined) return String(node.id);
- if (node.element && node.element.id !== undefined) return String(node.element.id);
- if (node.key !== undefined) return `${node.tag}:${node.key}`;
- return node.tag;
+ return valdiDebuggerTreeModel.id(node);
}
function getNodeKind(node) {
@@ -116,16 +130,7 @@ function getNodeKind(node) {
function normalizeLabelValue(value) {
if (value === undefined || value === null || value === '') return '';
- if (typeof value === 'string') return value.replace(/^"|"$/g, '').trim();
- if (typeof value === 'number' || typeof value === 'boolean') return String(value);
- if (Array.isArray(value)) {
- return value.map(normalizeLabelValue).filter(isReadableLabelToken).join(' ').trim();
- }
- try {
- return JSON.stringify(value);
- } catch {
- return String(value);
- }
+ return valdiDebuggerTreeModel.formatValue(value, 0).replace(/^"|"$/g, '').trim();
}
function isReadableLabelToken(value) {
@@ -191,7 +196,7 @@ function describeOverlayNode(node) {
}
function getNodeAttributes(node) {
- return (node.element && node.element.attributes) || {};
+ return valdiDebuggerTreeModel.attributes(node);
}
function getNumericAttribute(node, name) {
@@ -223,7 +228,9 @@ function hasScrollState(node) {
function hasAnyRenderableBounds(root) {
let found = false;
walk(root, node => {
- if (node.bounds || (node.element && node.element.frame)) found = true;
+ if (!node.bounds && !(node.element && node.element.frame)) return true;
+ found = true;
+ return false;
});
return found;
}
@@ -231,7 +238,9 @@ function hasAnyRenderableBounds(root) {
function hasLocalFrames(root) {
let found = false;
walk(root, node => {
- if (node.element && node.element.frame) found = true;
+ if (!node.element || !node.element.frame) return true;
+ found = true;
+ return false;
});
return found;
}
@@ -249,10 +258,14 @@ function unionBounds(boundsList) {
function firstElementWithFrame(root) {
let result = null;
walk(root, node => {
- if (!result && node.element && node.element.frame) {
+ if (node.element && node.element.frame) {
const bounds = normalizeBounds(node.element.frame);
- if (bounds.width > 0 && bounds.height > 0) result = node;
+ if (bounds.width > 0 && bounds.height > 0) {
+ result = node;
+ return false;
+ }
}
+ return true;
});
return result;
}
@@ -260,45 +273,64 @@ function firstElementWithFrame(root) {
function computeGeometry(root) {
const localFrames = hasLocalFrames(root);
const map = new Map();
-
- function visit(node, offset) {
- const frame = node.element && node.element.frame ? normalizeBounds(node.element.frame) : null;
- const absoluteCandidate = !localFrames && node.bounds ? normalizeBounds(node.bounds) : null;
- let absolute = null;
- let childOffset = offset;
-
- if (frame) {
- absolute = {
- x: offset.x + frame.x,
- y: offset.y + frame.y,
- width: frame.width,
- height: frame.height,
- };
- const scrollOffset = getScrollOffset(node);
- const translation = getTranslation(node);
- childOffset = {
- x: absolute.x - scrollOffset.x + translation.x,
- y: absolute.y - scrollOffset.y + translation.y,
- };
- } else if (absoluteCandidate) {
- absolute = absoluteCandidate;
- }
-
- const childBounds = (node.children || []).map(child => visit(child, childOffset)).filter(Boolean);
- if (!absolute || node.component) {
- absolute = unionBounds(childBounds) || absolute;
- }
-
+ const absoluteByNode = new Map();
+ const records = [];
+ const childrenByNode = new Map();
+ const childOffsets = new Map();
+ valdiDebuggerTreeModel.walk(
+ root,
+ (node, ancestors) => {
+ const parent = ancestors.at(-1);
+ const offset = parent ? childOffsets.get(parent) || { x: 0, y: 0 } : { x: 0, y: 0 };
+ if (parent) {
+ const children = childrenByNode.get(parent) || [];
+ children.push(node);
+ childrenByNode.set(parent, children);
+ }
+ childrenByNode.set(node, childrenByNode.get(node) || []);
+ const frame = node.element && node.element.frame ? normalizeBounds(node.element.frame) : null;
+ const absoluteCandidate = !localFrames && node.bounds ? normalizeBounds(node.bounds) : null;
+ let absolute = null;
+ let childOffset = offset;
+ if (frame) {
+ absolute = {
+ x: offset.x + frame.x,
+ y: offset.y + frame.y,
+ width: frame.width,
+ height: frame.height,
+ };
+ const scrollOffset = getScrollOffset(node);
+ const translation = getTranslation(node);
+ childOffset = {
+ x: absolute.x - scrollOffset.x + translation.x,
+ y: absolute.y - scrollOffset.y + translation.y,
+ };
+ } else if (absoluteCandidate) {
+ absolute = absoluteCandidate;
+ }
+ childOffsets.set(node, childOffset);
+ records.push({ absolute, frame, node });
+ },
+ [],
+ 0,
+ );
+ for (let index = records.length - 1; index >= 0; index -= 1) {
+ const item = records[index];
+ let absolute = item.absolute;
+ const childBounds = (childrenByNode.get(item.node) || [])
+ .map(child => absoluteByNode.get(child))
+ .filter(Boolean);
+ if (!absolute || item.node.component) absolute = unionBounds(childBounds) || absolute;
if (absolute) {
- map.set(getNodeId(node), {
- local: node.bounds ? normalizeBounds(node.bounds) : frame,
+ absoluteByNode.set(item.node, absolute);
+ map.set(getNodeId(item.node), {
+ local: item.node.bounds ? normalizeBounds(item.node.bounds) : item.frame,
absolute,
});
}
- return absolute;
}
- const rootBounds = visit(root, { x: 0, y: 0 });
+ const rootBounds = absoluteByNode.get(root) || null;
const viewportNode = localFrames ? firstElementWithFrame(root) : root;
const viewportGeometry = viewportNode ? map.get(getNodeId(viewportNode)) : null;
const viewport = viewportGeometry?.absolute || rootBounds || { x: 0, y: 0, width: 390, height: 760 };
@@ -647,26 +679,31 @@ function mergeIssues(existing, generated) {
}
function walk(node, visitor, parent = null, depth = 0) {
- visitor(node, parent, depth);
- for (const child of node.children || []) {
- walk(child, visitor, node, depth + 1);
- }
+ return valdiDebuggerTreeModel.walk(
+ node,
+ (current, ancestors, currentDepth) => {
+ return visitor(current, ancestors.at(-1) ?? parent, depth + currentDepth);
+ },
+ [],
+ 0,
+ );
}
function walkVisible(node, visitor, parent = null, depth = 0) {
- visitor(node, parent, depth);
- if (!state.expandedNodeIds.has(getNodeId(node))) return;
- for (const child of node.children || []) {
- walkVisible(child, visitor, node, depth + 1);
- }
+ if (!node) return true;
+ return valdiDebuggerTreeModel.walkVisible(
+ node,
+ (current, ancestors, currentDepth) => {
+ return visitor(current, ancestors.at(-1) ?? parent, depth + currentDepth);
+ },
+ current => state.expandedNodeIds.has(getNodeId(current)),
+ [],
+ 0,
+ );
}
function findNodeInTree(root, id) {
- let result = null;
- walk(root, node => {
- if (getNodeId(node) === String(id)) result = node;
- });
- return result;
+ return valdiDebuggerTreeModel.findNode(root, id);
}
function findNode(id) {
@@ -674,24 +711,8 @@ function findNode(id) {
return findNodeInTree(state.snapshot.tree, id);
}
-function getParentMap() {
- const parents = new Map();
- if (!hasSnapshotTree()) return parents;
- walk(state.snapshot.tree, (node, parent) => {
- if (parent) parents.set(getNodeId(node), parent);
- });
- return parents;
-}
-
function getPathToNode(id) {
- const parents = getParentMap();
- const path = [];
- let current = findNode(id);
- while (current) {
- path.unshift(current);
- current = parents.get(getNodeId(current));
- }
- return path;
+ return hasSnapshotTree() ? valdiDebuggerTreeModel.pathToNode(state.snapshot.tree, id) : [];
}
function expandPathToNode(id) {
@@ -713,7 +734,7 @@ function collapseTreeNode(node) {
function toggleTreeNode(id) {
const node = findNode(id);
- if (!node || !(node.children || []).length) return;
+ if (!node || !valdiDebuggerTreeModel.hasChildren(node)) return;
if (state.expandedNodeIds.has(id)) {
collapseTreeNode(node);
} else {
@@ -723,15 +744,24 @@ function toggleTreeNode(id) {
}
function ensureBounds(node, depth = 0, index = 0) {
- if (!node.bounds) {
- node.bounds = {
- x: 12 + depth * 16,
- y: 24 + index * 54 + depth * 14,
- width: Math.max(80, 360 - depth * 28),
- height: node.children && node.children.length ? 110 : 42,
- };
- }
- (node.children || []).forEach((child, childIndex) => ensureBounds(child, depth + 1, childIndex));
+ if (!node) return;
+ valdiDebuggerTreeModel.walk(
+ node,
+ (current, _ancestors, currentDepth, sourceChildIndex) => {
+ const currentIndex = sourceChildIndex === null ? index : sourceChildIndex;
+ const hasChildren = valdiDebuggerTreeModel.hasChildren(current);
+ if (!current.bounds) {
+ current.bounds = {
+ x: 12 + (depth + currentDepth) * 16,
+ y: 24 + currentIndex * 54 + (depth + currentDepth) * 14,
+ width: Math.max(80, 360 - (depth + currentDepth) * 28),
+ height: hasChildren ? 110 : 42,
+ };
+ }
+ },
+ [],
+ 0,
+ );
}
function escapeHtml(value) {
diff --git a/npm_modules/cli/debugger/debugger-preview-html.js b/npm_modules/cli/debugger/debugger-preview-html.js
index f83221546..d16f3c5ee 100644
--- a/npm_modules/cli/debugger/debugger-preview-html.js
+++ b/npm_modules/cli/debugger/debugger-preview-html.js
@@ -62,23 +62,27 @@ function beginHtmlPreviewIncarnation() {
}
function previewClassName(value) {
- return String(value || 'unknown')
+ return valdiDebuggerTreeModel
+ .formatValue(value || 'unknown', 0)
.replace(/[^a-z0-9_-]+/gi, '-')
.toLowerCase();
}
function previewValue(value) {
if (value === undefined || value === null) return '';
- if (typeof value === 'string') return value.replace(/^"|"$/g, '');
- if (typeof value === 'number' || typeof value === 'boolean') return String(value);
- if (typeof value === 'object' && value.path) return String(value.path);
- return normalizeLabelValue(value);
+ if (typeof value === 'object') {
+ const pathDescriptor = Object.getOwnPropertyDescriptor(value, 'path');
+ if (pathDescriptor && Object.prototype.hasOwnProperty.call(pathDescriptor, 'value')) {
+ return valdiDebuggerTreeModel.formatValue(pathDescriptor.value, 0).replace(/^"|"$/g, '');
+ }
+ }
+ return valdiDebuggerTreeModel.formatValue(value, 0).replace(/^"|"$/g, '');
}
function previewBoolean(value, fallback = false) {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
- const normalized = String(value).toLowerCase();
+ const normalized = valdiDebuggerTreeModel.formatValue(value, 0).toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
@@ -86,14 +90,14 @@ function previewBoolean(value, fallback = false) {
function previewNumber(value, fallback = 0) {
if (value === undefined || value === null || value === '') return fallback;
- const parsed = Number.parseFloat(String(value).replace(/^"|"$/g, ''));
+ const parsed = Number.parseFloat(valdiDebuggerTreeModel.formatValue(value, 0).replace(/^"|"$/g, ''));
return Number.isFinite(parsed) ? parsed : fallback;
}
function previewCssLength(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number') return `${value}px`;
- const normalized = String(value).replace(/^"|"$/g, '').trim();
+ const normalized = valdiDebuggerTreeModel.formatValue(value, 0).replace(/^"|"$/g, '').trim();
if (!normalized) return '';
if (/^-?\d+(\.\d+)?$/.test(normalized)) return `${normalized}px`;
return normalized;
@@ -118,7 +122,7 @@ function previewText(node, attrs) {
function previewImageSource(attrs) {
const source = attrs.src || attrs.source || attrs.url;
if (!source) return '';
- if (typeof source === 'string') return source.replace(/^"|"$/g, '');
+ if (typeof source === 'string') return previewValue(source);
if (typeof source === 'object') {
return previewValue(source.src || source.url || source.path || source.default || '');
}
@@ -126,7 +130,7 @@ function previewImageSource(attrs) {
}
function previewSafeMediaSource(source) {
- const normalized = String(source || '').trim();
+ const normalized = valdiDebuggerTreeModel.formatValue(source || '', 0).trim();
return /^(data|blob):/i.test(normalized) ? normalized : '';
}
@@ -210,7 +214,7 @@ function applyPreviewTextStyles(element, attrs) {
}
function createPreviewElement(node, effectivelyDisabled) {
- const tag = String(node.tag || 'view').toLowerCase();
+ const tag = valdiDebuggerTreeModel.formatValue(node.tag || 'view', 0).toLowerCase();
const attrs = getNodeAttributes(node);
if (tag === 'textfield') {
const input = document.createElement('input');
@@ -266,7 +270,7 @@ function createPreviewElement(node, effectivelyDisabled) {
function finishPreviewElement(element, node, target, effectivelyDisabled) {
const attrs = getNodeAttributes(node);
- const tag = String(node.tag || 'view').toLowerCase();
+ const tag = valdiDebuggerTreeModel.formatValue(node.tag || 'view', 0).toLowerCase();
const nodeId = getNodeId(node);
const elementId = getElementIdForNode(node);
element.classList.add('valdi-html-node', `valdi-html-${previewClassName(tag)}`);
@@ -274,7 +278,7 @@ function finishPreviewElement(element, node, target, effectivelyDisabled) {
element.classList.add('preview-interactive');
}
element.dataset.previewNodeId = nodeId;
- if (elementId !== null) element.dataset.previewElementId = String(elementId);
+ if (elementId !== null) element.dataset.previewElementId = valdiDebuggerTreeModel.formatValue(elementId, 0);
associateHtmlPreviewElement(element, target);
if (effectivelyDisabled) {
element.classList.add('preview-disabled');
@@ -314,16 +318,29 @@ function isHtmlPreviewEffectivelyDisabled(node, ancestorDisabled) {
function appendPreviewNode(node, parent, target, ancestorDisabled) {
if (!node) return;
- if (node.element) {
- const effectivelyDisabled = isHtmlPreviewEffectivelyDisabled(node, ancestorDisabled);
- const element = createPreviewElement(node, effectivelyDisabled);
- finishPreviewElement(element, node, target, effectivelyDisabled);
- parent.appendChild(element);
- const childParent = canHostPreviewChildren(element) ? element : parent;
- for (const child of node.children || []) appendPreviewNode(child, childParent, target, effectivelyDisabled);
- return;
- }
- for (const child of node.children || []) appendPreviewNode(child, parent, target, ancestorDisabled);
+ const childParents = new Map();
+ const disabledStates = new Map();
+ valdiDebuggerTreeModel.walk(
+ node,
+ (current, ancestors) => {
+ const directParent = ancestors.at(-1);
+ const currentParent = directParent ? childParents.get(directParent) || parent : parent;
+ const parentDisabled = directParent ? disabledStates.get(directParent) || false : ancestorDisabled;
+ if (!current.element) {
+ childParents.set(current, currentParent);
+ disabledStates.set(current, parentDisabled);
+ return;
+ }
+ const effectivelyDisabled = isHtmlPreviewEffectivelyDisabled(current, parentDisabled);
+ const element = createPreviewElement(current, effectivelyDisabled);
+ finishPreviewElement(element, current, target, effectivelyDisabled);
+ currentParent.appendChild(element);
+ childParents.set(current, canHostPreviewChildren(element) ? element : currentParent);
+ disabledStates.set(current, effectivelyDisabled);
+ },
+ [],
+ 0,
+ );
}
function canHostPreviewChildren(element) {
diff --git a/npm_modules/cli/debugger/debugger-render.js b/npm_modules/cli/debugger/debugger-render.js
index 5f47d1ec2..26037eae4 100644
--- a/npm_modules/cli/debugger/debugger-render.js
+++ b/npm_modules/cli/debugger/debugger-render.js
@@ -157,34 +157,40 @@ function renderDaemonStatus() {
function nodeMatchesSearch(node, search) {
if (!search) return true;
const id = getNodeId(node);
- const attributes = JSON.stringify(getNodeAttributes(node)).toLowerCase();
- const viewModel = JSON.stringify(node.viewModel || {}).toLowerCase();
- const component = JSON.stringify(node.component || {}).toLowerCase();
- const componentState = JSON.stringify(node.state || {}).toLowerCase();
+ const attributes = valdiDebuggerTreeModel.stringifyValue(getNodeAttributes(node), 0).toLowerCase();
+ const viewModel = valdiDebuggerTreeModel.stringifyValue(node.viewModel || {}, 0).toLowerCase();
+ const component = valdiDebuggerTreeModel.stringifyValue(node.component || {}, 0).toLowerCase();
+ const componentState = valdiDebuggerTreeModel.stringifyValue(node.state || {}, 0).toLowerCase();
const haystack = `${node.tag} ${id} #${id} ${attributes} ${viewModel} ${component} ${componentState}`.toLowerCase();
return haystack.includes(search);
}
function collectSearchVisibleNodeIds(root, search) {
const visibleNodeIds = new Set();
-
- function visit(node, ancestorMatched = false) {
- const id = getNodeId(node);
- const selfMatched = nodeMatchesSearch(node, search);
- let descendantMatched = false;
- for (const child of node.children || []) {
- if (visit(child, ancestorMatched || selfMatched)) {
- descendantMatched = true;
+ const matchedLineage = new Set();
+ const records = [];
+ valdiDebuggerTreeModel.walk(
+ root,
+ (node, ancestors) => {
+ const parent = ancestors.at(-1);
+ const ancestorMatched = parent ? matchedLineage.has(parent) : false;
+ const selfMatched = nodeMatchesSearch(node, search);
+ records.push({ node, parent, selfMatched });
+ if (ancestorMatched || selfMatched || getNodeId(node) === state.selectedNodeId) {
+ visibleNodeIds.add(getNodeId(node));
}
- }
-
- if (ancestorMatched || selfMatched || descendantMatched || id === state.selectedNodeId) {
- visibleNodeIds.add(id);
- }
- return selfMatched || descendantMatched;
+ if (ancestorMatched || selfMatched) matchedLineage.add(node);
+ },
+ [],
+ 0,
+ );
+ const matchedSubtrees = new Set();
+ for (let index = records.length - 1; index >= 0; index -= 1) {
+ const record = records[index];
+ if (!record.selfMatched && !matchedSubtrees.has(record.node)) continue;
+ visibleNodeIds.add(getNodeId(record.node));
+ if (record.parent) matchedSubtrees.add(record.parent);
}
-
- visit(root);
return visibleNodeIds;
}
@@ -212,7 +218,7 @@ function renderTree() {
const selected = isSelected ? ' selected' : '';
const hidden = searchVisibleNodeIds && !searchVisibleNodeIds.has(id) ? ' filtered-out' : '';
const kind = getNodeKind(node);
- const hasChildren = (node.children || []).length > 0;
+ const hasChildren = valdiDebuggerTreeModel.hasChildren(node);
const expanded = search || exactSearchRoot ? hasChildren : state.expandedNodeIds.has(id);
const toggleClass = hasChildren ? '' : ' empty';
const toggleLabel = hasChildren ? (expanded ? '-' : '+') : '';
@@ -331,12 +337,7 @@ function getNodeStatePayload(node) {
}
function payloadToDisplayString(payload) {
- if (typeof payload === 'string') return payload;
- try {
- return JSON.stringify(payload, null, 2);
- } catch {
- return String(payload);
- }
+ return valdiDebuggerTreeModel.formatValue(payload, 2);
}
function renderPayload(payload) {
@@ -354,11 +355,30 @@ function renderDataSection(title, body) {
function renderAttributesTable(attributes) {
const rows = Object.entries(attributes)
- .map(([key, value]) => `${escapeHtml(key)}
${escapeHtml(value)}
`)
+ .map(
+ ([key, value]) =>
+ `${escapeHtml(key)}
${escapeHtml(valdiDebuggerTreeModel.formatValue(value, 0))}
`,
+ )
.join('');
return rows ? `${rows}
` : '';
}
+function serializeRawInspectorNode(node, geometry, target) {
+ const projectedGeometry = valdiDebuggerTreeModel.projectValue(geometry);
+ const projectedNode = valdiDebuggerTreeModel.projectTree(node);
+ const projectedTarget = valdiDebuggerTreeModel.projectValue(target);
+ return JSON.stringify(
+ {
+ geometry: projectedGeometry.value,
+ node: projectedNode,
+ projectionComplete: projectedGeometry.complete && projectedNode.complete && projectedTarget.complete,
+ target: projectedTarget.value,
+ },
+ null,
+ 2,
+ );
+}
+
async function captureRootSnapshot(params, options = {}) {
if (!hasSnapshotTree()) {
state.rootSnapshotImage = null;
@@ -414,7 +434,7 @@ function renderInspector() {
Kind
${escapeHtml(kind)}
Node id
${escapeHtml(id)}
Key
${escapeHtml(node.key || 'n/a')}
- Children
${(node.children || []).length}
+ Children
${valdiDebuggerTreeModel.children(node).length}
Local bounds
${renderBounds(geometry?.local || node.bounds)}
Absolute bounds
${renderBounds(geometry?.absolute)}
${hasScrollState(node) ? `Host scroll offset
x ${scrollOffset.x}, y ${scrollOffset.y}
` : ''}
@@ -463,12 +483,8 @@ function renderInspector() {
}
if (tab === 'raw') {
- const payload = {
- node,
- geometry,
- target: state.snapshot.target,
- };
- elements.inspector.innerHTML = `${escapeHtml(JSON.stringify(payload, null, 2))}`;
+ const payload = serializeRawInspectorNode(node, geometry, state.snapshot.target);
+ elements.inspector.innerHTML = `${escapeHtml(payload)}`;
return;
}
diff --git a/npm_modules/cli/debugger/debugger-runtime.js b/npm_modules/cli/debugger/debugger-runtime.js
index 265493aa0..67f656b60 100644
--- a/npm_modules/cli/debugger/debugger-runtime.js
+++ b/npm_modules/cli/debugger/debugger-runtime.js
@@ -270,12 +270,18 @@ async function loadRealSnapshot(target, options = {}) {
function firstElementDescendant(node) {
if (!node) return null;
- if (node.element && node.element.id !== undefined) return node;
- for (const child of node.children || []) {
- const match = firstElementDescendant(child);
- if (match) return match;
- }
- return null;
+ let found = null;
+ valdiDebuggerTreeModel.walk(
+ node,
+ current => {
+ if (!current.element || current.element.id === undefined) return true;
+ found = current;
+ return false;
+ },
+ [],
+ 0,
+ );
+ return found;
}
async function captureSelectedElementSnapshot() {
@@ -363,9 +369,13 @@ async function copyPreview() {
}
try {
- await navigator.clipboard.writeText(JSON.stringify(state.snapshot, null, 2));
+ await navigator.clipboard.writeText(previewSnapshotProjectionJson());
addLog('info', 'preview', 'Copied current snapshot JSON.');
} catch (error) {
addLog('error', 'preview', `Copy failed: ${error.message}`);
}
}
+
+function previewSnapshotProjectionJson() {
+ return JSON.stringify(valdiDebuggerTreeModel.projectSnapshot(state.snapshot), null, 2);
+}
diff --git a/npm_modules/cli/debugger/debugger-tree-model.js b/npm_modules/cli/debugger/debugger-tree-model.js
new file mode 100644
index 000000000..a3c0250d9
--- /dev/null
+++ b/npm_modules/cli/debugger/debugger-tree-model.js
@@ -0,0 +1,539 @@
+// Transport-neutral Valdi hierarchy helpers shared by standalone and embedded DevTools.
+const MAX_DEBUGGER_TREE_NODES = 25_000;
+const MAX_DEBUGGER_PROJECTION_VALUES = 250_000;
+const MAX_DEBUGGER_PROJECTION_DEPTH = 64;
+const MAX_DEBUGGER_PROJECTION_STRING_LENGTH = 50_000;
+
+function debuggerJsonRecord() {
+ return Object.create(null);
+}
+
+function setDebuggerJsonProperty(target, key, value) {
+ Object.defineProperty(target, key, {
+ configurable: true,
+ enumerable: true,
+ value,
+ writable: true,
+ });
+}
+
+function debuggerProjectionTruncation(reason, path) {
+ const marker = debuggerJsonRecord();
+ setDebuggerJsonProperty(marker, '$at', path);
+ setDebuggerJsonProperty(marker, '$truncated', reason);
+ return marker;
+}
+
+function markDebuggerProjectionTruncated(target, reason, path) {
+ const marker = debuggerProjectionTruncation(reason, path);
+ if (Array.isArray(target)) {
+ target.push(marker);
+ } else if (target.$type === 'array' && Array.isArray(target.$entries)) {
+ target.$entries.push(marker);
+ } else {
+ setDebuggerJsonProperty(target, '$at', marker.$at);
+ setDebuggerJsonProperty(target, '$truncated', marker.$truncated);
+ }
+}
+
+function debuggerPrimitiveProjection(value, projectionState) {
+ if (typeof value === 'string') {
+ if (value.length <= MAX_DEBUGGER_PROJECTION_STRING_LENGTH) return value;
+ projectionState.complete = false;
+ const suffix = '…[truncated]';
+ return `${value.slice(0, MAX_DEBUGGER_PROJECTION_STRING_LENGTH - suffix.length)}${suffix}`;
+ }
+ if (value === null || typeof value === 'boolean') return value;
+ if (typeof value === 'number') return Number.isFinite(value) ? value : String(value);
+ if (typeof value === 'bigint') return `${value}n`;
+ if (typeof value === 'undefined') return '[undefined]';
+ if (typeof value === 'function') return `[Function ${value.name || 'anonymous'}]`;
+ if (typeof value === 'symbol') return String(value);
+ return undefined;
+}
+
+function isDebuggerArrayIndex(key) {
+ const index = Number(key);
+ return Number.isInteger(index) && index >= 0 && index < 4_294_967_295 && String(index) === key;
+}
+
+function debuggerOwnEntries(source, path, projectionState) {
+ let descriptors;
+ try {
+ descriptors = Object.getOwnPropertyDescriptors(source);
+ } catch {
+ projectionState.complete = false;
+ return { entries: [], inspectionError: debuggerProjectionTruncation('unavailable-properties', path) };
+ }
+
+ const entries = [];
+ for (const key of Object.keys(descriptors)) {
+ const descriptor = descriptors[key];
+ if (!descriptor?.enumerable) continue;
+ if (entries.length >= MAX_DEBUGGER_PROJECTION_VALUES) {
+ projectionState.complete = false;
+ break;
+ }
+ const childPath = isDebuggerArrayIndex(key) ? `${path}[${key}]` : `${path}.${key}`;
+ if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
+ projectionState.complete = false;
+ entries.push({ accessor: true, childPath, key, value: debuggerProjectionTruncation('accessor', childPath) });
+ continue;
+ }
+ entries.push({ accessor: false, childPath, key, value: descriptor.value });
+ }
+ return { descriptors, entries, inspectionError: null };
+}
+
+function debuggerProjectionFrame(source, targetPath, projectionState) {
+ const inspection = debuggerOwnEntries(source, targetPath, projectionState);
+ if (inspection.inspectionError) {
+ return { entries: [], sparse: false, target: inspection.inspectionError };
+ }
+
+ if (!Array.isArray(source)) {
+ return { entries: inspection.entries, sparse: false, target: debuggerJsonRecord() };
+ }
+
+ const lengthDescriptor = inspection.descriptors.length;
+ const length =
+ lengthDescriptor && Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value')
+ ? Number(lengthDescriptor.value)
+ : 0;
+ let expectedIndex = 0;
+ let dense = Number.isSafeInteger(length) && length >= 0;
+ for (const key of Object.keys(inspection.descriptors)) {
+ const descriptor = inspection.descriptors[key];
+ if (!descriptor?.enumerable) continue;
+ if (
+ !Object.prototype.hasOwnProperty.call(descriptor, 'value') ||
+ !isDebuggerArrayIndex(key) ||
+ Number(key) !== expectedIndex
+ ) {
+ dense = false;
+ break;
+ }
+ expectedIndex += 1;
+ }
+ if (expectedIndex !== length) dense = false;
+ if (dense) return { entries: inspection.entries, sparse: false, target: [] };
+
+ projectionState.complete = false;
+ const target = debuggerJsonRecord();
+ setDebuggerJsonProperty(target, '$at', targetPath);
+ setDebuggerJsonProperty(target, '$entries', []);
+ setDebuggerJsonProperty(target, '$length', Number.isSafeInteger(length) && length >= 0 ? length : '[unavailable]');
+ setDebuggerJsonProperty(target, '$truncated', 'sparse-array');
+ setDebuggerJsonProperty(target, '$type', 'array');
+ return {
+ entries: inspection.entries,
+ sparse: true,
+ target,
+ };
+}
+
+function setDebuggerProjectionEntry(frame, entry, value) {
+ if (frame.sparse) {
+ const projectedEntry = debuggerJsonRecord();
+ setDebuggerJsonProperty(
+ projectedEntry,
+ isDebuggerArrayIndex(entry.key) ? '$index' : '$key',
+ isDebuggerArrayIndex(entry.key) ? Number(entry.key) : entry.key,
+ );
+ setDebuggerJsonProperty(projectedEntry, 'value', value);
+ frame.target.$entries.push(projectedEntry);
+ } else {
+ setDebuggerJsonProperty(frame.target, entry.key, value);
+ }
+}
+
+function projectDebuggerValue(value, projectionState, path) {
+ if (value === null || typeof value !== 'object') {
+ if (projectionState.valueCount >= MAX_DEBUGGER_PROJECTION_VALUES) {
+ projectionState.complete = false;
+ return debuggerProjectionTruncation('value-limit', path);
+ }
+ projectionState.valueCount += 1;
+ return debuggerPrimitiveProjection(value, projectionState);
+ }
+ const knownPath = projectionState.seen.get(value);
+ if (knownPath !== undefined) {
+ if (projectionState.valueCount >= MAX_DEBUGGER_PROJECTION_VALUES) {
+ projectionState.complete = false;
+ return debuggerProjectionTruncation('value-limit', path);
+ }
+ projectionState.valueCount += 1;
+ const reference = debuggerJsonRecord();
+ setDebuggerJsonProperty(reference, '$ref', knownPath);
+ return reference;
+ }
+ if (projectionState.valueCount >= MAX_DEBUGGER_PROJECTION_VALUES) {
+ projectionState.complete = false;
+ return debuggerProjectionTruncation('value-limit', path);
+ }
+
+ const rootFrame = debuggerProjectionFrame(value, path, projectionState);
+ const root = rootFrame.target;
+ projectionState.seen.set(value, path);
+ projectionState.valueCount += 1;
+ const stack = [{ ...rootFrame, depth: 0, index: 0, path }];
+ while (stack.length) {
+ const frame = stack.at(-1);
+ if (frame.index >= frame.entries.length) {
+ stack.pop();
+ continue;
+ }
+ if (projectionState.valueCount >= MAX_DEBUGGER_PROJECTION_VALUES) {
+ projectionState.complete = false;
+ markDebuggerProjectionTruncated(frame.target, 'value-limit', frame.path);
+ stack.pop();
+ continue;
+ }
+
+ const entry = frame.entries[frame.index];
+ frame.index += 1;
+ const childValue = entry.value;
+ if (entry.accessor || childValue === null || typeof childValue !== 'object') {
+ setDebuggerProjectionEntry(
+ frame,
+ entry,
+ entry.accessor ? childValue : debuggerPrimitiveProjection(childValue, projectionState),
+ );
+ projectionState.valueCount += 1;
+ continue;
+ }
+ const childKnownPath = projectionState.seen.get(childValue);
+ if (childKnownPath !== undefined) {
+ const reference = debuggerJsonRecord();
+ setDebuggerJsonProperty(reference, '$ref', childKnownPath);
+ setDebuggerProjectionEntry(frame, entry, reference);
+ projectionState.valueCount += 1;
+ continue;
+ }
+ if (frame.depth + 1 >= MAX_DEBUGGER_PROJECTION_DEPTH) {
+ projectionState.complete = false;
+ setDebuggerProjectionEntry(frame, entry, debuggerProjectionTruncation('depth-limit', entry.childPath));
+ projectionState.valueCount += 1;
+ continue;
+ }
+
+ const childFrame = debuggerProjectionFrame(childValue, entry.childPath, projectionState);
+ setDebuggerProjectionEntry(frame, entry, childFrame.target);
+ projectionState.seen.set(childValue, entry.childPath);
+ projectionState.valueCount += 1;
+ stack.push({ ...childFrame, depth: frame.depth + 1, index: 0, path: entry.childPath });
+ }
+ return root;
+}
+
+function debuggerChildEntries(node) {
+ let childrenDescriptor;
+ try {
+ childrenDescriptor = Object.getOwnPropertyDescriptor(node, 'children');
+ } catch {
+ return { complete: false, entries: [] };
+ }
+ if (!childrenDescriptor) return { complete: true, entries: [] };
+ if (!Object.prototype.hasOwnProperty.call(childrenDescriptor, 'value')) {
+ return { complete: false, entries: [] };
+ }
+ const children = childrenDescriptor.value;
+ if (!Array.isArray(children)) return { complete: true, entries: [] };
+
+ let descriptors;
+ try {
+ descriptors = Object.getOwnPropertyDescriptors(children);
+ } catch {
+ return { complete: false, entries: [] };
+ }
+ const lengthDescriptor = descriptors.length;
+ const length =
+ lengthDescriptor && Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value')
+ ? Number(lengthDescriptor.value)
+ : 0;
+ const entries = [];
+ let complete = true;
+ let numericProperties = 0;
+ for (const key of Object.keys(descriptors)) {
+ if (!isDebuggerArrayIndex(key)) continue;
+ numericProperties += 1;
+ const descriptor = descriptors[key];
+ if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
+ complete = false;
+ continue;
+ }
+ if (entries.length >= MAX_DEBUGGER_TREE_NODES) {
+ complete = false;
+ break;
+ }
+ if (descriptor.value) entries.push({ index: Number(key), node: descriptor.value });
+ }
+ if (!Number.isSafeInteger(length) || length < 0 || numericProperties !== length) complete = false;
+ return { complete, entries };
+}
+
+function walkDebuggerTree(node, visitor, ancestors, depth, shouldDescend) {
+ if (!node) return true;
+
+ const path = ancestors.slice();
+ const visited = new Set(ancestors);
+ if (visited.has(node)) return true;
+ const stack = [{ childEntries: null, childIndex: 0, depth, entered: false, node, sourceChildIndex: null }];
+ let complete = true;
+ let visitedCount = 0;
+ while (stack.length) {
+ const frame = stack.at(-1);
+ if (!frame.entered) {
+ if (visited.has(frame.node)) {
+ stack.pop();
+ continue;
+ }
+ if (visitedCount >= MAX_DEBUGGER_TREE_NODES) return false;
+ visited.add(frame.node);
+ visitedCount += 1;
+ frame.entered = true;
+ if (visitor(frame.node, path, frame.depth, frame.sourceChildIndex) === false) return false;
+ path.push(frame.node);
+ if (!shouldDescend(frame.node, frame.depth)) {
+ frame.childEntries = [];
+ continue;
+ }
+ const children = debuggerChildEntries(frame.node);
+ if (!children.complete) complete = false;
+ frame.childEntries = children.entries;
+ continue;
+ }
+
+ let childEntry = null;
+ while (frame.childIndex < frame.childEntries.length && !childEntry) {
+ const candidate = frame.childEntries[frame.childIndex];
+ frame.childIndex += 1;
+ if (candidate.node && !visited.has(candidate.node)) childEntry = candidate;
+ }
+ if (childEntry) {
+ stack.push({
+ childEntries: null,
+ childIndex: 0,
+ depth: frame.depth + 1,
+ entered: false,
+ node: childEntry.node,
+ sourceChildIndex: childEntry.index,
+ });
+ continue;
+ }
+ stack.pop();
+ path.pop();
+ }
+ return complete;
+}
+
+const valdiDebuggerTreeModel = Object.freeze({
+ attributes(node) {
+ return node?.element?.attributes || {};
+ },
+
+ id(node) {
+ if (node?.id !== undefined) return String(node.id);
+ if (node?.element?.id !== undefined) return String(node.element.id);
+ if (node?.key !== undefined) return `${node.tag}:${node.key}`;
+ return node?.tag || '';
+ },
+
+ children(node) {
+ return node ? debuggerChildEntries(node).entries.map(entry => entry.node) : [];
+ },
+
+ hasChildren(node) {
+ return node ? debuggerChildEntries(node).entries.length > 0 : false;
+ },
+
+ walk(node, visitor, ancestors, depth) {
+ return walkDebuggerTree(node, visitor, ancestors, depth, () => true);
+ },
+
+ walkVisible(node, visitor, isExpanded, ancestors, depth) {
+ return walkDebuggerTree(node, visitor, ancestors, depth, isExpanded);
+ },
+
+ findNode(root, id) {
+ if (id === null || id === undefined) return null;
+ let found = null;
+ valdiDebuggerTreeModel.walk(
+ root,
+ node => {
+ if (valdiDebuggerTreeModel.id(node) !== String(id)) return true;
+ found = node;
+ return false;
+ },
+ [],
+ 0,
+ );
+ return found;
+ },
+
+ pathToNode(root, id) {
+ if (id === null || id === undefined) return [];
+ let path = [];
+ valdiDebuggerTreeModel.walk(
+ root,
+ (node, ancestors) => {
+ if (valdiDebuggerTreeModel.id(node) !== String(id)) return true;
+ path = [...ancestors, node];
+ return false;
+ },
+ [],
+ 0,
+ );
+ return path;
+ },
+
+ projectValue(value) {
+ const projectionState = {
+ complete: true,
+ seen: new Map(),
+ valueCount: 0,
+ };
+ const projected = projectDebuggerValue(value, projectionState, '$');
+ return {
+ complete: projectionState.complete,
+ value: projected,
+ };
+ },
+
+ stringifyValue(value, spacing) {
+ const projection = valdiDebuggerTreeModel.projectValue(value);
+ return JSON.stringify(projection.value, null, spacing);
+ },
+
+ formatValue(value, spacing) {
+ const projection = valdiDebuggerTreeModel.projectValue(value);
+ return typeof projection.value === 'string' ? projection.value : JSON.stringify(projection.value, null, spacing);
+ },
+
+ projectTree(root) {
+ if (!root) {
+ return {
+ complete: true,
+ format: 'valdi-debugger-tree-v1',
+ nodeCount: 0,
+ nodes: [],
+ rootIndex: null,
+ };
+ }
+
+ const nodes = [];
+ const nodeIndexes = new Map();
+ const projectionState = {
+ complete: true,
+ seen: new Map(),
+ valueCount: 0,
+ };
+ const traversalComplete = valdiDebuggerTreeModel.walk(
+ root,
+ (node, ancestors, depth, sourceChildIndex) => {
+ const index = nodes.length;
+ const parent = ancestors.at(-1);
+ const parentIndex = parent ? nodeIndexes.get(parent) : null;
+ nodeIndexes.set(node, index);
+ projectionState.seen.set(node, `$.nodes[${index}].data`);
+ const data = debuggerJsonRecord();
+ const inspection = debuggerOwnEntries(node, `$.nodes[${index}].data`, projectionState);
+ if (inspection.inspectionError) {
+ markDebuggerProjectionTruncated(data, 'unavailable-properties', `$.nodes[${index}].data`);
+ }
+ for (const entry of inspection.entries) {
+ if (entry.key === 'children') continue;
+ if (projectionState.valueCount >= MAX_DEBUGGER_PROJECTION_VALUES) {
+ projectionState.complete = false;
+ markDebuggerProjectionTruncated(data, 'value-limit', `$.nodes[${index}].data`);
+ break;
+ }
+ setDebuggerJsonProperty(
+ data,
+ entry.key,
+ entry.accessor ? entry.value : projectDebuggerValue(entry.value, projectionState, entry.childPath),
+ );
+ }
+ nodes.push({
+ childIndexes: [],
+ data,
+ depth,
+ index,
+ parentIndex: parentIndex === undefined ? null : parentIndex,
+ sourceChildIndex,
+ });
+ if (parentIndex !== null && parentIndex !== undefined) {
+ nodes[parentIndex].childIndexes.push(index);
+ }
+ },
+ [],
+ 0,
+ );
+ return {
+ complete: traversalComplete && projectionState.complete,
+ format: 'valdi-debugger-tree-v1',
+ nodeCount: nodes.length,
+ nodes,
+ rootIndex: 0,
+ };
+ },
+
+ projectSnapshot(snapshot) {
+ let metadata = debuggerJsonRecord();
+ try {
+ const descriptors = Object.getOwnPropertyDescriptors(snapshot && typeof snapshot === 'object' ? snapshot : {});
+ for (const key of Object.keys(descriptors)) {
+ if (key !== 'tree') Object.defineProperty(metadata, key, descriptors[key]);
+ }
+ } catch {
+ metadata = debuggerProjectionTruncation('unavailable-properties', '$');
+ }
+ const projectedMetadata = valdiDebuggerTreeModel.projectValue(metadata);
+ const projectedTree = valdiDebuggerTreeModel.projectTree(snapshot?.tree);
+ const projection = debuggerJsonRecord();
+ for (const key of Object.keys(projectedMetadata.value)) {
+ setDebuggerJsonProperty(projection, key, projectedMetadata.value[key]);
+ }
+ setDebuggerJsonProperty(projection, 'projectionComplete', projectedMetadata.complete && projectedTree.complete);
+ setDebuggerJsonProperty(projection, 'tree', projectedTree);
+ return projection;
+ },
+
+ restoreTree(value) {
+ if (
+ !value ||
+ value.format !== 'valdi-debugger-tree-v1' ||
+ !Array.isArray(value.nodes) ||
+ !Number.isInteger(value.rootIndex)
+ ) {
+ return value;
+ }
+ const nodes = value.nodes.map(record => {
+ const node = debuggerJsonRecord();
+ let descriptors;
+ try {
+ descriptors = Object.getOwnPropertyDescriptors(record?.data || debuggerJsonRecord());
+ } catch {
+ descriptors = debuggerJsonRecord();
+ }
+ for (const key of Object.keys(descriptors)) {
+ const descriptor = descriptors[key];
+ if (descriptor?.enumerable && Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
+ setDebuggerJsonProperty(node, key, descriptor.value);
+ }
+ }
+ return node;
+ });
+ for (let index = 0; index < value.nodes.length; index += 1) {
+ const childIndexes = Array.isArray(value.nodes[index]?.childIndexes) ? value.nodes[index].childIndexes : [];
+ setDebuggerJsonProperty(
+ nodes[index],
+ 'children',
+ childIndexes
+ .filter(childIndex => Number.isInteger(childIndex) && childIndex >= 0 && childIndex < nodes.length)
+ .map(childIndex => nodes[childIndex]),
+ );
+ }
+ return nodes[value.rootIndex] || null;
+ },
+});
diff --git a/npm_modules/cli/debugger/devtools-panel.css b/npm_modules/cli/debugger/devtools-panel.css
new file mode 100644
index 000000000..37f6bc1e5
--- /dev/null
+++ b/npm_modules/cli/debugger/devtools-panel.css
@@ -0,0 +1,610 @@
+/* Chromium-style shell shared by generic Owl/Chromium Valdi DevTools hosts. */
+:root {
+ color-scheme: light;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ font-size: 12px;
+ --background: #fff;
+ --surface: #f8f9fa;
+ --surface-hover: #f1f3f4;
+ --surface-selected: #e8f0fe;
+ --toolbar: #f1f3f4;
+ --border: #dadce0;
+ --border-soft: #e8eaed;
+ --text: #202124;
+ --muted: #5f6368;
+ --muted-soft: #80868b;
+ --accent: #1a73e8;
+ --accent-soft: #d2e3fc;
+ --tag: #881280;
+ --attribute: #994500;
+ --string: #1a1aa6;
+ --number: #1c00cf;
+ --success: #188038;
+ --warning: #e37400;
+ --error: #d93025;
+ --box-margin: #f9cc9d;
+ --box-border: #fddd9b;
+ --box-padding: #c7df9e;
+ --box-content: #a7c6e8;
+ --mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+}
+
+:root[data-theme='dark'] {
+ color-scheme: dark;
+ --background: #202124;
+ --surface: #292a2d;
+ --surface-hover: #303134;
+ --surface-selected: #263850;
+ --toolbar: #292a2d;
+ --border: #3c4043;
+ --border-soft: #303134;
+ --text: #e8eaed;
+ --muted: #9aa0a6;
+ --muted-soft: #80868b;
+ --accent: #8ab4f8;
+ --accent-soft: #31486a;
+ --tag: #ff7bff;
+ --attribute: #f6a35e;
+ --string: #a5d6ff;
+ --number: #a5d6ff;
+ --success: #81c995;
+ --warning: #fdd663;
+ --error: #f28b82;
+ --box-margin: #8a694b;
+ --box-border: #8a7143;
+ --box-padding: #61764f;
+ --box-content: #48637e;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+}
+
+body {
+ background: var(--background);
+ color: var(--text);
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button {
+ color: inherit;
+}
+
+.devtools-app {
+ display: grid;
+ width: 100%;
+ height: 100%;
+ grid-template-rows: 31px 30px minmax(0, 1fr);
+}
+
+.main-toolbar,
+.target-toolbar,
+.inspector-toolbar,
+.filter-toolbar,
+.breadcrumb-bar {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ border-bottom: 1px solid var(--border);
+}
+
+.main-toolbar {
+ background: var(--toolbar);
+}
+
+.main-tabs,
+.detail-tabs {
+ display: flex;
+ min-width: 0;
+ height: 100%;
+ align-items: stretch;
+ overflow: hidden;
+}
+
+.main-tab,
+.detail-tab {
+ position: relative;
+ min-width: 0;
+ border: 0;
+ background: transparent;
+ color: var(--text);
+ white-space: nowrap;
+}
+
+.main-tab {
+ padding: 0 10px;
+}
+
+.detail-tab {
+ padding: 0 9px;
+}
+
+.main-tab:hover,
+.detail-tab:hover,
+.icon-button:hover {
+ background: var(--surface-hover);
+}
+
+.main-tab.selected,
+.detail-tab.selected {
+ color: var(--accent);
+}
+
+.main-tab.selected::after,
+.detail-tab.selected::after {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 2px;
+ background: var(--accent);
+ content: '';
+}
+
+.toolbar-spacer {
+ flex: 1 1 auto;
+}
+
+.icon-button {
+ display: inline-grid;
+ width: 28px;
+ height: 26px;
+ flex: 0 0 auto;
+ place-items: center;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+}
+
+.icon-button svg,
+.filter-icon {
+ width: 16px;
+ height: 16px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.4;
+}
+
+.target-toolbar {
+ gap: 7px;
+ padding: 0 9px;
+ background: var(--background);
+}
+
+.status-dot {
+ width: 7px;
+ height: 7px;
+ flex: 0 0 auto;
+ border-radius: 50%;
+ background: var(--success);
+}
+
+.status-dot.connecting {
+ background: var(--warning);
+}
+
+.status-dot.error {
+ background: var(--error);
+}
+
+.target-name {
+ min-width: 0;
+ overflow: hidden;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.target-metadata {
+ overflow: hidden;
+ color: var(--muted);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.live-toggle {
+ display: flex;
+ margin-left: auto;
+ align-items: center;
+ color: var(--muted);
+ gap: 3px;
+ white-space: nowrap;
+}
+
+.live-toggle input {
+ width: 13px;
+ height: 13px;
+ margin: 0;
+ accent-color: var(--accent);
+}
+
+.main-content,
+.section {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+}
+
+.main-content {
+ display: grid;
+ overflow: hidden;
+}
+
+.section {
+ display: none;
+}
+
+.section.selected {
+ display: grid;
+ height: 100%;
+ overflow: hidden;
+}
+
+#elementsSection {
+ grid-template-rows: minmax(120px, 1fr) 5px minmax(170px, 42%) 24px;
+}
+
+.tree-pane {
+ position: relative;
+ display: grid;
+ min-height: 0;
+ grid-template-rows: 28px minmax(0, 1fr);
+}
+
+.filter-toolbar {
+ padding: 0 5px 0 8px;
+ color: var(--muted);
+ gap: 6px;
+}
+
+.filter-icon {
+ width: 14px;
+ height: 14px;
+ flex: 0 0 auto;
+}
+
+#treeFilter,
+#consoleInput {
+ min-width: 0;
+ flex: 1 1 auto;
+ border: 0;
+ outline: none;
+ background: transparent;
+ color: var(--text);
+}
+
+#treeFilter::placeholder,
+#consoleInput::placeholder {
+ color: var(--muted-soft);
+}
+
+.filter-summary,
+.node-summary {
+ flex: 0 0 auto;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.tree {
+ min-height: 0;
+ overflow: auto;
+ font-family: var(--mono);
+ font-size: 11px;
+}
+
+.tree:focus-visible {
+ outline: none;
+}
+
+.tree:focus-visible .tree-row.selected {
+ outline: 1px solid var(--accent);
+ outline-offset: -1px;
+}
+
+.tree-row {
+ display: flex;
+ min-width: max-content;
+ height: 20px;
+ align-items: center;
+ padding-right: 10px;
+ white-space: nowrap;
+}
+
+.tree-row:hover {
+ background: var(--surface-hover);
+}
+
+.tree-row.selected {
+ background: var(--surface-selected);
+}
+
+.disclosure {
+ display: inline-grid;
+ width: 15px;
+ height: 19px;
+ flex: 0 0 auto;
+ place-items: center;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.disclosure.empty {
+ visibility: hidden;
+}
+
+.tag-bracket {
+ color: var(--muted);
+}
+
+.tag-name {
+ color: var(--tag);
+}
+
+.attribute-name {
+ padding-left: 5px;
+ color: var(--attribute);
+}
+
+.attribute-value {
+ color: var(--string);
+}
+
+.tree-text {
+ max-width: 240px;
+ overflow: hidden;
+ color: var(--muted);
+ text-overflow: ellipsis;
+}
+
+.empty-state {
+ padding: 18px 12px;
+ color: var(--muted);
+}
+
+.tree:not(:empty) + .empty-state {
+ display: none;
+}
+
+.tree + .empty-state {
+ position: absolute;
+ top: 28px;
+ right: 0;
+ left: 0;
+}
+
+.split-handle {
+ border-top: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+ cursor: row-resize;
+}
+
+.split-handle:hover {
+ background: var(--accent-soft);
+}
+
+.inspector-pane {
+ display: grid;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ grid-template-rows: 30px minmax(0, 1fr);
+}
+
+.inspector-toolbar {
+ background: var(--surface);
+}
+
+.inspector {
+ min-height: 0;
+ padding: 9px 11px 14px;
+ overflow: hidden auto;
+ overscroll-behavior: contain;
+ font-family: var(--mono);
+ font-size: 11px;
+ line-height: 18px;
+}
+
+.rule-header {
+ margin: 0 0 5px;
+ color: var(--text);
+ font-weight: 500;
+}
+
+.rule-origin {
+ float: right;
+ color: var(--muted);
+}
+
+.property-list {
+ margin: 0 0 12px;
+ padding-left: 13px;
+}
+
+.property-row {
+ overflow-wrap: anywhere;
+}
+
+.property-name {
+ color: var(--attribute);
+}
+
+.property-value.string {
+ color: var(--string);
+}
+
+.property-value.number,
+.property-value.boolean {
+ color: var(--number);
+}
+
+.property-value.empty {
+ color: var(--muted);
+}
+
+.box-model {
+ width: min(100%, 360px);
+ margin: 14px auto;
+ color: #202124;
+ text-align: center;
+}
+
+.box-layer {
+ position: relative;
+ min-height: 34px;
+ padding: 14px 18px 8px;
+ border: 1px dashed rgba(32, 33, 36, 0.45);
+}
+
+.box-layer.margin {
+ background: var(--box-margin);
+}
+
+.box-layer.border {
+ background: var(--box-border);
+}
+
+.box-layer.padding {
+ background: var(--box-padding);
+}
+
+.box-layer.content {
+ padding: 7px;
+ background: var(--box-content);
+}
+
+.box-label {
+ position: absolute;
+ top: 1px;
+ left: 5px;
+ font-size: 9px;
+}
+
+.computed-list {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.computed-list td {
+ padding: 5px 7px;
+ border-bottom: 1px solid var(--border-soft);
+ text-align: left;
+ vertical-align: top;
+}
+
+.computed-list td:first-child {
+ width: 42%;
+ color: var(--muted);
+}
+
+.json-view {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.breadcrumb-bar {
+ padding: 0 8px;
+ border-top: 1px solid var(--border);
+ border-bottom: 0;
+ background: var(--surface);
+}
+
+.breadcrumbs {
+ min-width: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+ color: var(--muted);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.breadcrumb {
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+}
+
+.breadcrumb:last-child {
+ color: var(--accent);
+}
+
+.console-section {
+ min-height: 0;
+ grid-template-rows: minmax(0, 1fr) 32px;
+}
+
+.console-messages {
+ min-height: 0;
+ overflow: auto;
+ font-family: var(--mono);
+ font-size: 11px;
+}
+
+.console-entry {
+ display: grid;
+ min-height: 25px;
+ padding: 5px 8px;
+ border-bottom: 1px solid var(--border-soft);
+ gap: 8px;
+ grid-template-columns: 10px minmax(0, 1fr);
+}
+
+.console-entry.error {
+ background: color-mix(in srgb, var(--error) 8%, var(--background));
+ color: var(--error);
+}
+
+.console-entry.result .console-chevron,
+.console-entry.input .console-chevron {
+ color: var(--accent);
+}
+
+.console-entry pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.console-prompt {
+ display: flex;
+ align-items: center;
+ padding: 0 8px;
+ border-top: 1px solid var(--border);
+ gap: 8px;
+}
+
+.console-chevron {
+ color: var(--accent);
+ font-size: 16px;
+}
+
+@media (max-width: 480px) {
+ .main-tab {
+ padding: 0 7px;
+ }
+
+ .detail-tab {
+ padding: 0 6px;
+ }
+
+ .target-metadata {
+ display: none;
+ }
+}
diff --git a/npm_modules/cli/debugger/devtools-panel.html b/npm_modules/cli/debugger/devtools-panel.html
new file mode 100644
index 000000000..7cf083814
--- /dev/null
+++ b/npm_modules/cli/debugger/devtools-panel.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+ Valdi DevTools
+
+
+
+
+
+
+
+
diff --git a/npm_modules/cli/debugger/devtools-panel.js b/npm_modules/cli/debugger/devtools-panel.js
new file mode 100644
index 000000000..0b9224b56
--- /dev/null
+++ b/npm_modules/cli/debugger/devtools-panel.js
@@ -0,0 +1,742 @@
+const query = new URLSearchParams(window.location.search);
+const inspectedUrl = query.get('inspectedUrl');
+const inspectedTargetNonce = query.get('targetNonce');
+const MAX_CONSOLE_ENTRIES = 500;
+const MAX_CONSOLE_ENTRY_CHARACTERS = 50_000;
+const MAX_CONSOLE_HISTORY_ENTRIES = 100;
+
+const state = {
+ target: null,
+ snapshot: null,
+ activeSection: 'elements',
+ activeDetail: 'styles',
+ selectedNodeId: null,
+ remoteSelectedNodeId: null,
+ expandedNodeIds: new Set(),
+ search: '',
+ autoRefresh: true,
+ refreshTimer: null,
+ refreshPending: false,
+ hoveredNodeId: null,
+ highlightTimer: null,
+ consoleEntries: [],
+ consoleHistory: [],
+ consoleHistoryIndex: 0,
+ error: null,
+};
+
+const elements = {
+ mainTabs: Array.from(document.querySelectorAll('.main-tab')),
+ detailTabs: Array.from(document.querySelectorAll('.detail-tab')),
+ sections: Array.from(document.querySelectorAll('.section')),
+ targetStatusDot: document.getElementById('targetStatusDot'),
+ targetName: document.getElementById('targetName'),
+ targetMetadata: document.getElementById('targetMetadata'),
+ autoRefreshToggle: document.getElementById('autoRefreshToggle'),
+ refreshButton: document.getElementById('refreshButton'),
+ treeFilter: document.getElementById('treeFilter'),
+ filterSummary: document.getElementById('filterSummary'),
+ tree: document.getElementById('tree'),
+ treeEmpty: document.getElementById('treeEmpty'),
+ expandButton: document.getElementById('expandButton'),
+ inspector: document.getElementById('inspector'),
+ copyNodeButton: document.getElementById('copyNodeButton'),
+ breadcrumbs: document.getElementById('breadcrumbs'),
+ nodeSummary: document.getElementById('nodeSummary'),
+ elementsSection: document.getElementById('elementsSection'),
+ splitHandle: document.getElementById('splitHandle'),
+ consoleMessages: document.getElementById('consoleMessages'),
+ consoleForm: document.getElementById('consoleForm'),
+ consoleInput: document.getElementById('consoleInput'),
+};
+
+function escapeHtml(value) {
+ return String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function applyTheme(theme) {
+ document.documentElement.dataset.theme = theme === 'dark' ? 'dark' : 'light';
+}
+
+async function requestJson(path, params, options) {
+ const url = new URL(path, window.location.origin);
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined && value !== null && value !== '') {
+ url.searchParams.set(key, String(value));
+ }
+ }
+
+ const response = await fetch(url, {
+ method: options.body ? 'POST' : 'GET',
+ cache: 'no-store',
+ ...(options.body
+ ? {
+ body: JSON.stringify(options.body),
+ headers: { 'Content-Type': 'application/json' },
+ }
+ : {}),
+ });
+ const payload = await response.json();
+ if (!response.ok || payload.error) {
+ throw new Error(payload.error || `Request failed: ${response.status}`);
+ }
+ return payload;
+}
+
+function formatNumber(value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return '—';
+ return Number.isInteger(numeric)
+ ? numeric.toLocaleString()
+ : numeric.toLocaleString(undefined, { maximumFractionDigits: 1 });
+}
+
+function nodeAttributes(node) {
+ return valdiDebuggerTreeModel.attributes(node);
+}
+
+function nodeId(node) {
+ return valdiDebuggerTreeModel.id(node);
+}
+
+function treeRowId(id) {
+ return `valdi-tree-node-${encodeURIComponent(id)}`;
+}
+
+function nodeText(node) {
+ const attributes = nodeAttributes(node);
+ const candidates = [attributes.accessibilityLabel, node?.element?.dom?.textContent, attributes.value];
+ for (const candidate of candidates) {
+ if (candidate === undefined || candidate === null || candidate === '') continue;
+ const formatted = valdiDebuggerTreeModel.formatValue(candidate, 0).trim();
+ if (formatted) return formatted;
+ }
+ return '';
+}
+
+function walk(node, callback) {
+ valdiDebuggerTreeModel.walk(node, callback, [], 0);
+}
+
+function walkVisible(node, callback) {
+ valdiDebuggerTreeModel.walkVisible(
+ node,
+ callback,
+ current => state.expandedNodeIds.has(nodeId(current)),
+ [],
+ 0,
+ );
+}
+
+function nodeCount() {
+ let count = 0;
+ walk(state.snapshot?.tree, () => count++);
+ return count;
+}
+
+function findNode(id) {
+ return valdiDebuggerTreeModel.findNode(state.snapshot?.tree, id);
+}
+
+function findNodePath(id) {
+ return valdiDebuggerTreeModel.pathToNode(state.snapshot?.tree, id);
+}
+
+function selectedNodeProjectionJson() {
+ const node = findNode(state.selectedNodeId);
+ return node ? JSON.stringify(valdiDebuggerTreeModel.projectTree(node), null, 2) : null;
+}
+
+function chooseInitialNode(root) {
+ let selected = null;
+ walk(root, node => {
+ const attributes = nodeAttributes(node);
+ if (attributes.accessibilityId || attributes.accessibilityLabel || typeof attributes.value === 'string') {
+ selected = node;
+ return false;
+ }
+ return true;
+ });
+ return selected || root;
+}
+
+function revealPath(id) {
+ const path = findNodePath(id);
+ for (const node of path.slice(0, -1)) {
+ state.expandedNodeIds.add(nodeId(node));
+ }
+}
+
+function expandUsefulNodes(root) {
+ if (!root) return;
+ let current = root;
+ let depth = 0;
+ while (current && depth < 8) {
+ state.expandedNodeIds.add(nodeId(current));
+ const children = valdiDebuggerTreeModel.children(current);
+ if (children.length !== 1) break;
+ current = children[0];
+ depth++;
+ }
+ for (const child of valdiDebuggerTreeModel.children(current)) {
+ if (valdiDebuggerTreeModel.hasChildren(child) && depth < 5) {
+ state.expandedNodeIds.add(nodeId(child));
+ }
+ }
+}
+
+function setConnected(connected, message) {
+ elements.targetStatusDot.className = `status-dot${connected ? '' : state.error ? ' error' : ' connecting'}`;
+ if (message) elements.targetName.textContent = message;
+}
+
+function reportError(error) {
+ const message = error instanceof Error ? error.message : String(error);
+ state.error = message;
+ setConnected(false, message);
+ elements.treeEmpty.textContent = message;
+}
+
+async function connectToInspectedApplication() {
+ if (!inspectedUrl || !inspectedTargetNonce) {
+ state.error = 'The DevTools extension did not provide its inspected page identity.';
+ setConnected(false, state.error);
+ return;
+ }
+
+ try {
+ const payload = await requestJson(
+ '/api/devtools/target',
+ { inspectedUrl, targetNonce: inspectedTargetNonce },
+ {},
+ );
+ state.target = payload.target;
+ elements.targetName.textContent = state.target.name || 'Valdi application';
+ elements.targetName.title = state.target.applicationUrl || inspectedUrl;
+ elements.targetMetadata.textContent = `Chromium · :${state.target.debuggingPort}`;
+ setConnected(true);
+ addConsoleEntry('info', `Connected to ${state.target.applicationUrl}`);
+ await refreshSnapshot();
+ startRefreshTimer();
+ } catch (error) {
+ reportError(error);
+ window.setTimeout(connectToInspectedApplication, 1500);
+ }
+}
+
+async function refreshSnapshot() {
+ if (!state.target || state.refreshPending) return;
+ state.refreshPending = true;
+ try {
+ const snapshot = await requestJson(
+ '/api/devtools/snapshot',
+ { inspectedUrl, sessionId: state.target.sessionId, targetNonce: inspectedTargetNonce },
+ {},
+ );
+ snapshot.tree = valdiDebuggerTreeModel.restoreTree(snapshot.tree);
+ const wasEmpty = !state.snapshot?.tree;
+ state.snapshot = snapshot;
+ state.error = null;
+ setConnected(true);
+
+ if (wasEmpty) {
+ expandUsefulNodes(snapshot.tree);
+ state.selectedNodeId = nodeId(chooseInitialNode(snapshot.tree));
+ revealPath(state.selectedNodeId);
+ }
+ if (snapshot.selectedNodeId && snapshot.selectedNodeId !== state.remoteSelectedNodeId) {
+ state.remoteSelectedNodeId = String(snapshot.selectedNodeId);
+ state.selectedNodeId = state.remoteSelectedNodeId;
+ revealPath(state.selectedNodeId);
+ }
+ if (!findNode(state.selectedNodeId)) {
+ state.selectedNodeId = nodeId(chooseInitialNode(snapshot.tree));
+ revealPath(state.selectedNodeId);
+ }
+ render();
+ } catch (error) {
+ reportError(error);
+ } finally {
+ state.refreshPending = false;
+ }
+}
+
+function startRefreshTimer() {
+ if (state.refreshTimer) window.clearInterval(state.refreshTimer);
+ state.refreshTimer = window.setInterval(() => {
+ if (!state.autoRefresh || document.hidden || state.activeSection !== 'elements') return;
+ void refreshSnapshot();
+ }, 1200);
+}
+
+function visibleSearchIds(root, search) {
+ const ids = new Set();
+ const normalized = search.toLowerCase();
+ const records = [];
+ walk(root, (node, ancestors) => {
+ const metadata = valdiDebuggerTreeModel.stringifyValue(nodeAttributes(node), 0);
+ const text = `${node.tag} ${nodeText(node)} ${metadata}`.toLowerCase();
+ records.push({ matched: text.includes(normalized), node, parent: ancestors.at(-1) });
+ });
+ const matchedSubtrees = new Set();
+ for (let index = records.length - 1; index >= 0; index -= 1) {
+ const record = records[index];
+ if (!record.matched && !matchedSubtrees.has(record.node)) continue;
+ ids.add(nodeId(record.node));
+ if (record.parent) matchedSubtrees.add(record.parent);
+ }
+ return ids;
+}
+
+function selectedAttribute(node) {
+ const attributes = nodeAttributes(node);
+ if (attributes.accessibilityId) {
+ return ['id', valdiDebuggerTreeModel.formatValue(attributes.accessibilityId, 0)];
+ }
+ if (attributes.accessibilityCategory && attributes.accessibilityCategory !== 'view') {
+ return ['role', valdiDebuggerTreeModel.formatValue(attributes.accessibilityCategory, 0)];
+ }
+ if (attributes.onTap || attributes.touchEnabled) return ['interactive', 'true'];
+ return null;
+}
+
+function renderTree() {
+ const root = state.snapshot?.tree;
+ if (!root) {
+ elements.tree.innerHTML = '';
+ elements.tree.removeAttribute('aria-activedescendant');
+ elements.filterSummary.textContent = '';
+ return;
+ }
+
+ const search = state.search.trim();
+ const visibleIds = search ? visibleSearchIds(root, search) : null;
+ const rows = [];
+ let matches = 0;
+ const normalizedSearch = search.toLowerCase();
+ const traversal = search ? walk : walkVisible;
+ traversal(root, (node, _ancestors, depth) => {
+ const id = nodeId(node);
+ if (visibleIds && !visibleIds.has(id)) return;
+ const hasChildren = valdiDebuggerTreeModel.hasChildren(node);
+ const expanded = Boolean(search) || state.expandedNodeIds.has(id);
+ const selected = id === state.selectedNodeId;
+ const attribute = selectedAttribute(node);
+ const text = nodeText(node);
+ const attributes = nodeAttributes(node);
+ const serializedAttributes = valdiDebuggerTreeModel.stringifyValue(attributes, 0);
+ if (!search || `${node.tag} ${text} ${serializedAttributes}`.toLowerCase().includes(normalizedSearch)) {
+ matches++;
+ }
+ rows.push(`
+
+
+ <${escapeHtml(node.tag || 'view')}
+ ${attribute ? `${escapeHtml(attribute[0])}="${escapeHtml(attribute[1])}"` : ''}>
+ ${text ? `${escapeHtml(text)}` : ''}
+
+ `);
+ });
+
+ const scrollTop = elements.tree.scrollTop;
+ elements.tree.innerHTML = rows.join('');
+ elements.tree.scrollTop = scrollTop;
+ const selectedRow = document.getElementById(treeRowId(state.selectedNodeId));
+ if (selectedRow && elements.tree.contains(selectedRow)) {
+ elements.tree.setAttribute('aria-activedescendant', selectedRow.id);
+ } else {
+ elements.tree.removeAttribute('aria-activedescendant');
+ }
+ elements.filterSummary.textContent = search ? `${matches} match${matches === 1 ? '' : 'es'}` : '';
+}
+
+function renderValue(value) {
+ if (value === undefined || value === null) return 'null';
+ const type = typeof value;
+ const serialized = valdiDebuggerTreeModel.formatValue(value, 0);
+ const printable = serialized === undefined ? String(value) : serialized;
+ const formatted = printable.length > 160 ? `${printable.slice(0, 157)}…` : printable;
+ return `${escapeHtml(formatted)}`;
+}
+
+function propertyRows(attributes, options) {
+ const entries = Object.entries(attributes || {}).sort(([first], [second]) => first.localeCompare(second));
+ if (!entries.length) return 'No properties available.
';
+ return entries
+ .map(
+ ([key, value]) =>
+ `${escapeHtml(key)}: ${renderValue(value)}${options.css ? ';' : ''}
`,
+ )
+ .join('');
+}
+
+function renderStyles(node) {
+ const attributes = nodeAttributes(node);
+ const domStyle = node.element?.dom?.attributes?.style
+ ? valdiDebuggerTreeModel.formatValue(node.element.dom.attributes.style, 0)
+ : '';
+ return `
+
+ {
${propertyRows(attributes, { css: true })}
}
+ ${
+ domStyle
+ ? `{
${domStyle
+ .split(';')
+ .map(rule => rule.trim())
+ .filter(Boolean)
+ .map(rule => {
+ const [name, ...value] = rule.split(':');
+ return `
${escapeHtml(name)}: ${escapeHtml(value.join(':').trim())};
`;
+ })
+ .join('')}
}
`
+ : ''
+ }
+ `;
+}
+
+function edgeValues(attributes, prefix) {
+ const all = attributes[prefix];
+ return {
+ top: attributes[`${prefix}Top`] ?? all ?? 0,
+ right: attributes[`${prefix}Right`] ?? all ?? 0,
+ bottom: attributes[`${prefix}Bottom`] ?? all ?? 0,
+ left: attributes[`${prefix}Left`] ?? all ?? 0,
+ };
+}
+
+function renderComputed(node) {
+ const attributes = nodeAttributes(node);
+ const bounds = node.bounds || {};
+ const margin = edgeValues(attributes, 'margin');
+ const padding = edgeValues(attributes, 'padding');
+ const computed = {
+ width: `${formatNumber(bounds.width)} px`,
+ height: `${formatNumber(bounds.height)} px`,
+ x: `${formatNumber(bounds.x)} px`,
+ y: `${formatNumber(bounds.y)} px`,
+ display: attributes.flexDirection ? 'flex' : 'block',
+ position: attributes.position || 'relative',
+ ...(attributes.flexDirection ? { 'flex-direction': attributes.flexDirection } : {}),
+ ...(attributes.alignItems ? { 'align-items': attributes.alignItems } : {}),
+ ...(attributes.justifyContent ? { 'justify-content': attributes.justifyContent } : {}),
+ ...(attributes.gap !== undefined ? { gap: `${attributes.gap} px` } : {}),
+ ...(attributes.backgroundColor ? { background: attributes.backgroundColor } : {}),
+ ...(attributes.color ? { color: attributes.color } : {}),
+ };
+ return `
+
+
margin ${escapeHtml(valdiDebuggerTreeModel.formatValue(margin.top, 0))} ${escapeHtml(valdiDebuggerTreeModel.formatValue(margin.right, 0))} ${escapeHtml(valdiDebuggerTreeModel.formatValue(margin.bottom, 0))} ${escapeHtml(valdiDebuggerTreeModel.formatValue(margin.left, 0))}
+
border
+
padding ${escapeHtml(valdiDebuggerTreeModel.formatValue(padding.top, 0))} ${escapeHtml(valdiDebuggerTreeModel.formatValue(padding.right, 0))} ${escapeHtml(valdiDebuggerTreeModel.formatValue(padding.bottom, 0))} ${escapeHtml(valdiDebuggerTreeModel.formatValue(padding.left, 0))}
+
${formatNumber(bounds.width)} × ${formatNumber(bounds.height)}
+
+
+
+
+ ${Object.entries(computed)
+ .map(
+ ([key, value]) =>
+ `| ${escapeHtml(key)} | ${escapeHtml(valdiDebuggerTreeModel.formatValue(value, 0))} |
`,
+ )
+ .join('')}
+ `;
+}
+
+function renderInspector() {
+ const node = findNode(state.selectedNodeId);
+ if (!node) {
+ elements.inspector.innerHTML = 'Select a Valdi element to inspect it.
';
+ return;
+ }
+
+ if (state.activeDetail === 'styles') {
+ elements.inspector.innerHTML = renderStyles(node);
+ } else if (state.activeDetail === 'computed') {
+ elements.inspector.innerHTML = renderComputed(node);
+ } else {
+ const textContent = node.element?.dom?.textContent
+ ? valdiDebuggerTreeModel.formatValue(node.element.dom.textContent, 0)
+ : '';
+ elements.inspector.innerHTML = `${propertyRows(node.element?.dom?.attributes, { css: false })}${textContent ? `${escapeHtml(textContent)}` : ''}`;
+ }
+}
+
+function renderBreadcrumbs() {
+ const path = findNodePath(state.selectedNodeId);
+ elements.breadcrumbs.innerHTML = path
+ .map(
+ (node, index) =>
+ `${index ? ' › ' : ''}`,
+ )
+ .join('');
+ elements.nodeSummary.textContent = `${nodeCount()} nodes`;
+}
+
+function render() {
+ renderTree();
+ renderInspector();
+ renderBreadcrumbs();
+}
+
+function selectNode(id) {
+ const node = findNode(id);
+ if (!node) return;
+ state.selectedNodeId = nodeId(node);
+ revealPath(state.selectedNodeId);
+ render();
+}
+
+function scrollSelectedTreeRowIntoView() {
+ document.getElementById(treeRowId(state.selectedNodeId))?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+}
+
+function handleTreeNavigation(event) {
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
+ const key = event.key;
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key)) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ const rows = Array.from(elements.tree.querySelectorAll('.tree-row'));
+ if (!rows.length) return;
+
+ const selectedIndex = rows.findIndex(row => row.dataset.nodeId === state.selectedNodeId);
+ const selectedNode = findNode(state.selectedNodeId);
+ let nextNodeId = null;
+
+ if (key === 'ArrowUp') {
+ nextNodeId = rows[Math.max(0, selectedIndex - 1)]?.dataset.nodeId;
+ } else if (key === 'ArrowDown') {
+ nextNodeId = rows[Math.min(rows.length - 1, selectedIndex + 1)]?.dataset.nodeId;
+ } else if (key === 'Home') {
+ nextNodeId = rows[0].dataset.nodeId;
+ } else if (key === 'End') {
+ nextNodeId = rows[rows.length - 1].dataset.nodeId;
+ } else if (key === 'ArrowRight' && selectedNode) {
+ const children = valdiDebuggerTreeModel.children(selectedNode);
+ const expanded = Boolean(state.search.trim()) || state.expandedNodeIds.has(state.selectedNodeId);
+ if (children.length && !expanded) {
+ state.expandedNodeIds.add(state.selectedNodeId);
+ renderTree();
+ } else if (children.length) {
+ nextNodeId = rows[selectedIndex + 1]?.dataset.nodeId;
+ }
+ } else if (key === 'ArrowLeft' && selectedNode) {
+ if (
+ valdiDebuggerTreeModel.hasChildren(selectedNode) &&
+ state.expandedNodeIds.has(state.selectedNodeId) &&
+ !state.search.trim()
+ ) {
+ state.expandedNodeIds.delete(state.selectedNodeId);
+ renderTree();
+ } else {
+ const path = findNodePath(state.selectedNodeId);
+ nextNodeId = path.length > 1 ? nodeId(path[path.length - 2]) : null;
+ }
+ }
+
+ if (nextNodeId && nextNodeId !== state.selectedNodeId) selectNode(nextNodeId);
+ scrollSelectedTreeRowIntoView();
+}
+
+function queueHighlight(nodeIdValue) {
+ if (!state.target || state.hoveredNodeId === nodeIdValue) return;
+ state.hoveredNodeId = nodeIdValue;
+ if (state.highlightTimer) window.clearTimeout(state.highlightTimer);
+ state.highlightTimer = window.setTimeout(
+ () => {
+ void requestJson(
+ '/api/devtools/highlight',
+ {},
+ {
+ body: {
+ inspectedUrl,
+ sessionId: state.target.sessionId,
+ targetNonce: inspectedTargetNonce,
+ ...(nodeIdValue ? { nodeId: nodeIdValue } : {}),
+ },
+ },
+ ).catch(error => console.warn('Unable to update the inspected Valdi highlight.', error));
+ },
+ nodeIdValue ? 80 : 20,
+ );
+}
+
+function setActiveSection(section) {
+ state.activeSection = section;
+ for (const tab of elements.mainTabs) {
+ const selected = tab.dataset.section === section;
+ tab.classList.toggle('selected', selected);
+ tab.setAttribute('aria-selected', String(selected));
+ }
+ for (const panel of elements.sections) {
+ panel.classList.toggle('selected', panel.dataset.panel === section);
+ }
+ if (section === 'console') elements.consoleInput.focus();
+ if (section === 'elements') void refreshSnapshot();
+}
+
+function setActiveDetail(detail) {
+ state.activeDetail = detail;
+ for (const tab of elements.detailTabs) {
+ const selected = tab.dataset.detail === detail;
+ tab.classList.toggle('selected', selected);
+ tab.setAttribute('aria-selected', String(selected));
+ }
+ renderInspector();
+}
+
+function addConsoleEntry(kind, value) {
+ const text = String(value);
+ const boundedValue =
+ text.length > MAX_CONSOLE_ENTRY_CHARACTERS
+ ? `${text.slice(0, MAX_CONSOLE_ENTRY_CHARACTERS - 1)}…`
+ : text;
+ state.consoleEntries.push({ kind, value: boundedValue });
+ if (state.consoleEntries.length > MAX_CONSOLE_ENTRIES) {
+ state.consoleEntries.splice(0, state.consoleEntries.length - MAX_CONSOLE_ENTRIES);
+ }
+ elements.consoleMessages.innerHTML = state.consoleEntries
+ .map(
+ entry =>
+ `${entry.kind === 'input' ? '›' : entry.kind === 'error' ? '×' : '‹'}${escapeHtml(entry.value)} `,
+ )
+ .join('');
+ elements.consoleMessages.scrollTop = elements.consoleMessages.scrollHeight;
+}
+
+async function evaluateConsoleExpression(expression) {
+ addConsoleEntry('input', expression);
+ try {
+ const result = await requestJson(
+ '/api/devtools/evaluate',
+ {},
+ {
+ body: {
+ expression,
+ inspectedUrl,
+ sessionId: state.target.sessionId,
+ targetNonce: inspectedTargetNonce,
+ },
+ },
+ );
+ const serialized = result.type === 'undefined' ? undefined : JSON.stringify(result.value, null, 2);
+ const value = result.type === 'undefined' ? 'undefined' : (serialized ?? String(result.value));
+ addConsoleEntry('result', value);
+ } catch (error) {
+ addConsoleEntry('error', error.message);
+ }
+}
+
+function startSplitResize(event) {
+ event.preventDefault();
+ const bounds = elements.elementsSection.getBoundingClientRect();
+ function resize(moveEvent) {
+ const treeHeight = Math.max(120, Math.min(bounds.height - 200, moveEvent.clientY - bounds.top));
+ elements.elementsSection.style.gridTemplateRows = `${treeHeight}px 5px minmax(170px, 1fr) 24px`;
+ }
+ function stopResize() {
+ window.removeEventListener('pointermove', resize);
+ window.removeEventListener('pointerup', stopResize);
+ }
+ window.addEventListener('pointermove', resize);
+ window.addEventListener('pointerup', stopResize);
+}
+
+function wireEvents() {
+ for (const tab of elements.mainTabs) {
+ tab.addEventListener('click', () => setActiveSection(tab.dataset.section));
+ }
+ for (const tab of elements.detailTabs) {
+ tab.addEventListener('click', () => setActiveDetail(tab.dataset.detail));
+ }
+ elements.refreshButton.addEventListener('click', () => void refreshSnapshot());
+ elements.autoRefreshToggle.addEventListener('change', () => {
+ state.autoRefresh = elements.autoRefreshToggle.checked;
+ });
+ elements.treeFilter.addEventListener('input', () => {
+ state.search = elements.treeFilter.value;
+ renderTree();
+ });
+ elements.expandButton.addEventListener('click', () => {
+ expandUsefulNodes(state.snapshot?.tree);
+ if (state.selectedNodeId) revealPath(state.selectedNodeId);
+ renderTree();
+ });
+ elements.tree.addEventListener('keydown', handleTreeNavigation);
+ elements.tree.addEventListener('click', event => {
+ const toggle = event.target.closest('[data-toggle-id]');
+ if (toggle) {
+ elements.tree.focus({ preventScroll: true });
+ const id = toggle.dataset.toggleId;
+ if (state.expandedNodeIds.has(id)) {
+ state.expandedNodeIds.delete(id);
+ } else {
+ state.expandedNodeIds.add(id);
+ }
+ renderTree();
+ return;
+ }
+ const row = event.target.closest('[data-node-id]');
+ if (row) {
+ elements.tree.focus({ preventScroll: true });
+ selectNode(row.dataset.nodeId);
+ }
+ });
+ elements.tree.addEventListener('pointerover', event => {
+ const row = event.target.closest('[data-node-id]');
+ if (row) queueHighlight(row.dataset.nodeId);
+ });
+ elements.tree.addEventListener('pointerleave', () => queueHighlight(null));
+ elements.breadcrumbs.addEventListener('click', event => {
+ const button = event.target.closest('[data-breadcrumb-id]');
+ if (button) selectNode(button.dataset.breadcrumbId);
+ });
+ elements.copyNodeButton.addEventListener('click', async () => {
+ const json = selectedNodeProjectionJson();
+ if (json) await navigator.clipboard.writeText(json);
+ });
+ elements.splitHandle.addEventListener('pointerdown', startSplitResize);
+ elements.consoleForm.addEventListener('submit', event => {
+ event.preventDefault();
+ const expression = elements.consoleInput.value.trim();
+ if (!expression || !state.target) return;
+ state.consoleHistory.push(expression);
+ if (state.consoleHistory.length > MAX_CONSOLE_HISTORY_ENTRIES) {
+ state.consoleHistory.splice(0, state.consoleHistory.length - MAX_CONSOLE_HISTORY_ENTRIES);
+ }
+ state.consoleHistoryIndex = state.consoleHistory.length;
+ elements.consoleInput.value = '';
+ void evaluateConsoleExpression(expression);
+ });
+ elements.consoleInput.addEventListener('keydown', event => {
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
+ event.preventDefault();
+ state.consoleHistoryIndex = Math.max(
+ 0,
+ Math.min(state.consoleHistory.length, state.consoleHistoryIndex + (event.key === 'ArrowUp' ? -1 : 1)),
+ );
+ elements.consoleInput.value = state.consoleHistory[state.consoleHistoryIndex] || '';
+ });
+ window.addEventListener('message', event => {
+ if (
+ event.source === window.parent &&
+ event.origin.startsWith('chrome-extension://') &&
+ event.data?.channel === 'valdi-devtools-theme'
+ ) {
+ applyTheme(event.data.theme);
+ }
+ });
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden && state.activeSection === 'elements') void refreshSnapshot();
+ });
+}
+
+applyTheme(query.get('theme'));
+wireEvents();
+void connectToInspectedApplication();
diff --git a/npm_modules/cli/debugger/index.html b/npm_modules/cli/debugger/index.html
index c26deabda..924f44f9a 100644
--- a/npm_modules/cli/debugger/index.html
+++ b/npm_modules/cli/debugger/index.html
@@ -207,6 +207,7 @@ Performance
+
diff --git a/npm_modules/cli/src/commands/debugger.ts b/npm_modules/cli/src/commands/debugger.ts
index d1d3af308..e91d01be7 100644
--- a/npm_modules/cli/src/commands/debugger.ts
+++ b/npm_modules/cli/src/commands/debugger.ts
@@ -1,43 +1,104 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
import type { Argv } from 'yargs';
import { startDebuggerServer } from '../debugger/server';
import type { ArgumentsResolver } from '../utils/ArgumentsResolver';
import { makeCommandHandler } from '../utils/errorUtils';
+import { writeOwlDevToolsExtension } from '../utils/owlDevToolsExtension';
interface CommandParameters {
host: string;
port: number;
strictPort: boolean;
json: boolean;
+ webPreviewUrl?: string;
+ chromiumDebuggingPort: number;
}
async function waitForShutdown(closeServer: () => Promise): Promise {
return new Promise((resolve, reject) => {
let shuttingDown = false;
+ const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
const shutdown = () => {
if (shuttingDown) return;
shuttingDown = true;
- process.off('SIGINT', shutdown);
- process.off('SIGTERM', shutdown);
+ for (const signal of signals) process.off(signal, shutdown);
void closeServer().then(resolve, reject);
};
- process.once('SIGINT', shutdown);
- process.once('SIGTERM', shutdown);
+ for (const signal of signals) process.once(signal, shutdown);
});
}
-async function valdiDebugger(argv: ArgumentsResolver): Promise {
+export async function valdiDebugger(argv: ArgumentsResolver): Promise {
const host = argv.getArgument('host');
const port = argv.getArgument('port');
const strictPort = argv.getArgument('strictPort');
const json = argv.getArgument('json');
+ const webPreviewUrlArgument = argv.getArgument('webPreviewUrl');
+ const chromiumDebuggingPort = argv.getArgument('chromiumDebuggingPort');
+ let applicationUrl: URL | undefined;
+ if (webPreviewUrlArgument !== undefined) {
+ const normalizedWebPreviewUrl = webPreviewUrlArgument.trim();
+ if (!normalizedWebPreviewUrl) {
+ throw new Error('The --web-preview-url option must not be blank.');
+ }
+ try {
+ applicationUrl = new URL(normalizedWebPreviewUrl);
+ } catch {
+ throw new Error(`Invalid web preview URL: ${normalizedWebPreviewUrl}`);
+ }
+ }
+ const webPreviewUrl = applicationUrl?.toString();
+ const inspectedUrl = applicationUrl ? new URL(applicationUrl) : undefined;
+ inspectedUrl?.searchParams.set('valdiDebugger', '1');
+ inspectedUrl?.searchParams.set('valdiDevTools', '1');
const debuggerServer = await startDebuggerServer({
host,
port,
strictPort,
+ chromiumDebuggingPort,
+ ...(webPreviewUrl ? { webPreviewUrl } : {}),
});
+ let extensionDirectory: string | undefined;
+ try {
+ if (webPreviewUrl) {
+ extensionDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'valdi-devtools-extension-'));
+ writeOwlDevToolsExtension(extensionDirectory, debuggerServer.url);
+ }
+ } catch (error) {
+ try {
+ await debuggerServer.close();
+ } catch (closeError) {
+ console.warn(`Could not close the Valdi debugger after extension setup failed: ${String(closeError)}`);
+ } finally {
+ if (extensionDirectory) fs.rmSync(extensionDirectory, { force: true, recursive: true });
+ }
+ throw error;
+ }
+ let extensionExitCleanup: (() => void) | undefined;
+ if (extensionDirectory) {
+ extensionExitCleanup = () => fs.rmSync(extensionDirectory, { force: true, recursive: true });
+ process.once('exit', extensionExitCleanup);
+ }
+ const cleanupExtension = (): void => {
+ if (extensionExitCleanup) {
+ process.off('exit', extensionExitCleanup);
+ extensionExitCleanup = undefined;
+ }
+ if (extensionDirectory) fs.rmSync(extensionDirectory, { force: true, recursive: true });
+ };
+ const close = async (): Promise => {
+ try {
+ await debuggerServer.close();
+ } finally {
+ cleanupExtension();
+ }
+ };
+
if (json) {
console.log(
JSON.stringify({
@@ -47,6 +108,8 @@ async function valdiDebugger(argv: ArgumentsResolver): Promis
requestedPort: debuggerServer.requestedPort,
portWasAutoSelected: debuggerServer.portWasAutoSelected,
pid: process.pid,
+ ...(extensionDirectory ? { extensionDirectory } : {}),
+ ...(inspectedUrl ? { inspectedUrl: inspectedUrl.toString(), chromiumDebuggingPort } : {}),
}),
);
} else {
@@ -55,9 +118,16 @@ async function valdiDebugger(argv: ArgumentsResolver): Promis
if (debuggerServer.portWasAutoSelected) {
console.log(`Port ${debuggerServer.requestedPort} was busy; using ${debuggerServer.port}.`);
}
+ if (extensionDirectory && inspectedUrl) {
+ console.log(`Valdi DevTools extension: ${extensionDirectory}`);
+ console.log(`Open this exact preview URL: ${inspectedUrl.toString()}`);
+ console.log(
+ `Start Owl/Chromium with --remote-debugging-port=${chromiumDebuggingPort} --load-extension=${extensionDirectory}`,
+ );
+ }
}
- await waitForShutdown(debuggerServer.close);
+ await waitForShutdown(close);
}
export const command = 'debugger';
@@ -83,6 +153,15 @@ export const builder = (yargs: Argv) => {
describe: 'Print startup information as one JSON object for automation',
type: 'boolean',
default: false,
+ })
+ .option('web-preview-url', {
+ describe: 'Exact loopback web/Owl application URL to expose in the integrated Chromium DevTools panel',
+ type: 'string',
+ })
+ .option('chromium-debugging-port', {
+ describe: 'Loopback Chromium remote debugging port used with --web-preview-url',
+ type: 'number',
+ default: Number.parseInt(process.env['VALDI_CHROMIUM_DEBUGGING_PORT'] || '9222', 10),
});
};
diff --git a/npm_modules/cli/src/core/packageFiles.spec.ts b/npm_modules/cli/src/core/packageFiles.spec.ts
index 737d078d1..51084d411 100644
--- a/npm_modules/cli/src/core/packageFiles.spec.ts
+++ b/npm_modules/cli/src/core/packageFiles.spec.ts
@@ -50,6 +50,10 @@ describe('npm package contents', () => {
expect(packedFiles).toContain('dist/');
});
+ it('does not publish compiled Jasmine specifications', () => {
+ expect(packedFiles).not.toMatch(/dist\/.*\.spec\.js/);
+ });
+
it('includes bundled-skills', () => {
expect(packedFiles).toContain('bundled-skills/');
});
@@ -92,9 +96,10 @@ describe('npm package contents', () => {
});
it('does not let projected trees auto-load remote media', () => {
+ const treeModelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-tree-model.js'), 'utf8');
const previewSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-preview-html.js'), 'utf8');
const policyResult = new vm.Script(
- `${previewSource}\n[
+ `${treeModelSource}\n${previewSource}\n[
previewSafeMediaSource('https://example.com/image.png'),
previewSafeMediaSource('http://127.0.0.1:8080/private'),
previewSafeMediaSource('data:image/png;base64,AA=='),
@@ -108,9 +113,10 @@ describe('npm package contents', () => {
});
it('projects effective ancestor, accessibility, and touch-disabled state', () => {
+ const treeModelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-tree-model.js'), 'utf8');
const previewSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-preview-html.js'), 'utf8');
const disabledStates = new vm.Script(
- `${previewSource}
+ `${treeModelSource}\n${previewSource}
[
isHtmlPreviewEffectivelyDisabled({ attributes: {} }, true),
isHtmlPreviewEffectivelyDisabled({ attributes: { enabled: false } }, false),
@@ -127,6 +133,7 @@ describe('npm package contents', () => {
});
it('orders editable HTML preview focus and blur around text and key input', async () => {
+ const treeModelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-tree-model.js'), 'utf8');
const previewSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-preview-html.js'), 'utf8');
const bootstrapSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-bootstrap.js'), 'utf8');
const dispatchedInputs: Array<{
@@ -160,7 +167,7 @@ describe('npm package contents', () => {
return Promise.resolve({ handled: true });
};
const operation = new vm.Script(
- `${previewSource}
+ `${treeModelSource}\n${previewSource}
(async () => {
const target = Object.freeze({ port: 13_591, clientId: 'client-a', contextId: 'context-a' });
associateHtmlPreviewElement(editableTarget, target);
@@ -219,6 +226,7 @@ describe('npm package contents', () => {
});
it('keeps the runtime key path authoritative for projected textarea Return', async () => {
+ const treeModelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-tree-model.js'), 'utf8');
const previewSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-preview-html.js'), 'utf8');
const dispatchedInputs: Array> = [];
class FakeInputElement {}
@@ -242,7 +250,7 @@ describe('npm package contents', () => {
}
const textarea = new FakeTextAreaElement();
const operation = new vm.Script(
- `${previewSource}
+ `${treeModelSource}\n${previewSource}
(async () => {
const target = Object.freeze({ port: 13_591, clientId: 'client-a', contextId: 'context-a' });
associateHtmlPreviewElement(textarea, target);
@@ -338,6 +346,7 @@ describe('npm package contents', () => {
});
it('rejects delayed input reconciliation after ABA edits and refocus', async () => {
+ const treeModelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-tree-model.js'), 'utf8');
const previewSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-preview-html.js'), 'utf8');
const keyResponse = createDeferred>();
const dispatchedTypes: string[] = [];
@@ -367,7 +376,7 @@ describe('npm package contents', () => {
}
const textarea = new FakeTextAreaElement();
const operation = new vm.Script(
- `${previewSource}
+ `${treeModelSource}\n${previewSource}
(async () => {
const target = Object.freeze({ port: 13_591, clientId: 'client-a', contextId: 'context-a' });
associateHtmlPreviewElement(textarea, target);
@@ -447,6 +456,7 @@ describe('npm package contents', () => {
});
it('cancels pending projected input when the preview incarnation changes', async () => {
+ const treeModelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-tree-model.js'), 'utf8');
const previewSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-preview-html.js'), 'utf8');
const cancelledReservations: number[] = [];
const dispatchedInputs: Array<{ payload: Record; reservationId: number }> = [];
@@ -494,7 +504,7 @@ describe('npm package contents', () => {
};
const target = Object.freeze({ port: 13_591, clientId: 'client-a', contextId: 'context-a' });
const operation = new vm.Script(
- `${previewSource}
+ `${treeModelSource}\n${previewSource}
(async () => {
associateHtmlPreviewElement(oldInput, target);
associateHtmlPreviewElement(oldScroll, target);
@@ -609,6 +619,7 @@ describe('npm package contents', () => {
});
it('invalidates released text and wheel input still queued when the preview incarnation changes', async () => {
+ const treeModelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-tree-model.js'), 'utf8');
const modelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-model.js'), 'utf8');
const previewSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-preview-html.js'), 'utf8');
const firstResponse = createDeferred<{ input: { handled: boolean } }>();
@@ -728,7 +739,7 @@ describe('npm package contents', () => {
},
});
new vm.Script(
- `${modelSource}
+ `${treeModelSource}\n${modelSource}
findNode = nodeId => nodes[nodeId] || null;
findNodeAtPoint = (_point, predicate) => predicate(activeScrollNode.current) ? activeScrollNode.current : null;
getElementIdForNode = node => node?.elementId ?? null;
@@ -791,6 +802,7 @@ describe('npm package contents', () => {
});
it('reserves debounced HTML input in event order across different timer deadlines and targets', async () => {
+ const treeModelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-tree-model.js'), 'utf8');
const modelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-model.js'), 'utf8');
const previewSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-preview-html.js'), 'utf8');
interface FakeNode {
@@ -904,7 +916,7 @@ describe('npm package contents', () => {
},
};
const setup = new vm.Script(
- `${modelSource}
+ `${treeModelSource}\n${modelSource}
findNode = testFindNode;
findNodeAtPoint = testFindNodeAtPoint;
getElementIdForNode = node => node?.elementId ?? null;
diff --git a/npm_modules/cli/src/debugger/debuggerTreeModel.spec.ts b/npm_modules/cli/src/debugger/debuggerTreeModel.spec.ts
new file mode 100644
index 000000000..b37a276a1
--- /dev/null
+++ b/npm_modules/cli/src/debugger/debuggerTreeModel.spec.ts
@@ -0,0 +1,896 @@
+import 'jasmine';
+import fs from 'node:fs';
+import path from 'node:path';
+import { Script } from 'node:vm';
+
+interface DebugTreeNode {
+ children?: DebugTreeNode[];
+ element?: {
+ attributes?: Record;
+ id?: number | string;
+ };
+ id?: number | string;
+ key?: string;
+ tag: string;
+}
+
+interface DebuggerTreeModel {
+ attributes(node: DebugTreeNode | null): Record;
+ children(node: DebugTreeNode | null): DebugTreeNode[];
+ findNode(root: DebugTreeNode | null, id: number | string): DebugTreeNode | null;
+ formatValue(value: unknown, spacing: number): string;
+ hasChildren(node: DebugTreeNode | null): boolean;
+ id(node: DebugTreeNode | null): string;
+ pathToNode(root: DebugTreeNode | null, id: number | string): DebugTreeNode[];
+ projectSnapshot(snapshot: Record): Record;
+ projectTree(root: DebugTreeNode | null): {
+ complete: boolean;
+ nodeCount: number;
+ nodes: Array<{
+ childIndexes: number[];
+ data: Record;
+ depth: number;
+ index: number;
+ parentIndex: number | null;
+ sourceChildIndex: number | null;
+ }>;
+ };
+ projectValue(value: unknown): { complete: boolean; value: unknown };
+ restoreTree(value: unknown): DebugTreeNode | null;
+ stringifyValue(value: unknown, spacing: number): string;
+ walk(
+ root: DebugTreeNode | undefined,
+ visitor: (
+ node: DebugTreeNode,
+ ancestors: DebugTreeNode[],
+ depth: number,
+ sourceChildIndex: number | null,
+ ) => boolean | void,
+ ancestors: DebugTreeNode[],
+ depth: number,
+ ): boolean;
+ walkVisible(
+ root: DebugTreeNode | undefined,
+ visitor: (node: DebugTreeNode, ancestors: DebugTreeNode[], depth: number) => boolean | void,
+ isExpanded: (node: DebugTreeNode) => boolean,
+ ancestors: DebugTreeNode[],
+ depth: number,
+ ): boolean;
+}
+
+interface DebuggerModelHarness {
+ decorateSnapshot(snapshot: Record): Record;
+ normalizeLabelValue(value: unknown): string;
+}
+
+interface MockElement {
+ checked: boolean;
+ className: string;
+ classList: { toggle(): void };
+ contentWindow: null;
+ dataset: Record;
+ innerHTML: string;
+ scrollHeight: number;
+ scrollTop: number;
+ textContent: string;
+ title: string;
+ value: string;
+ addEventListener(): void;
+ contains(): boolean;
+ focus(): void;
+ removeAttribute(): void;
+ setAttribute(): void;
+}
+
+function createMockElement(): MockElement {
+ return {
+ checked: false,
+ className: '',
+ classList: { toggle: () => {} },
+ contentWindow: null,
+ dataset: {},
+ innerHTML: '',
+ scrollHeight: 0,
+ scrollTop: 0,
+ textContent: '',
+ title: '',
+ value: '',
+ addEventListener: () => {},
+ contains: () => false,
+ focus: () => {},
+ removeAttribute: () => {},
+ setAttribute: () => {},
+ };
+}
+
+function loadDebuggerModel(): {
+ model: DebuggerModelHarness;
+ state: { geometry: { map: { size: number } } | null };
+} {
+ const treeSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8');
+ const modelSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-model.js'), 'utf8');
+ const state = { expandedNodeIds: new Set(), geometry: null };
+ const model = new Script(
+ `${treeSource}\n${modelSource}\n({ decorateSnapshot, normalizeLabelValue })`,
+ ).runInNewContext({
+ emptyTarget: {},
+ state,
+ }) as DebuggerModelHarness;
+ return { model, state };
+}
+
+function loadDevToolsPanel(): {
+ elements: { tree: MockElement };
+ renderValue(value: unknown): string;
+ renderTree(): void;
+ revealPath(id: string): void;
+ selectedNodeProjectionJson(): string | null;
+ state: {
+ expandedNodeIds: Set;
+ search: string;
+ selectedNodeId: string | null;
+ snapshot: { tree: DebugTreeNode } | null;
+ };
+ visibleSearchIds(root: DebugTreeNode, search: string): Set;
+} {
+ const treeSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8');
+ const panelSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'devtools-panel.js'), 'utf8');
+ const bootIndex = panelSource.indexOf("\napplyTheme(query.get('theme'));");
+ if (bootIndex < 0) throw new Error('Could not isolate the DevTools panel definitions.');
+ const mockElements = new Map();
+ const getElement = (id: string): MockElement => {
+ let element = mockElements.get(id);
+ if (!element) {
+ element = createMockElement();
+ mockElements.set(id, element);
+ }
+ return element;
+ };
+ const panel = new Script(
+ `${treeSource}\n${panelSource.slice(0, bootIndex)}\n({ elements, renderTree, renderValue, revealPath, selectedNodeProjectionJson, state, visibleSearchIds })`,
+ ).runInNewContext({
+ URL,
+ URLSearchParams,
+ document: {
+ documentElement: { dataset: {} },
+ getElementById: getElement,
+ querySelectorAll: () => [],
+ },
+ window: { location: { origin: 'http://127.0.0.1:8765', search: '' } },
+ }) as {
+ elements: { tree: MockElement };
+ renderValue(value: unknown): string;
+ renderTree(): void;
+ revealPath(id: string): void;
+ selectedNodeProjectionJson(): string | null;
+ state: {
+ expandedNodeIds: Set;
+ search: string;
+ selectedNodeId: string | null;
+ snapshot: { tree: DebugTreeNode } | null;
+ };
+ visibleSearchIds(root: DebugTreeNode, search: string): Set;
+ };
+ return panel;
+}
+
+function loadPreviewAppender(): {
+ appendPreviewNode(node: DebugTreeNode, parent: PreviewElement): void;
+ createdElements: PreviewElement[];
+ previewValue(value: unknown): string;
+} {
+ const treeSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8');
+ const previewSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-preview-html.js'), 'utf8');
+ const createdElements: PreviewElement[] = [];
+ const document = {
+ createElement: (tag: string): PreviewElement => {
+ const element = createPreviewElement(tag);
+ createdElements.push(element);
+ return element;
+ },
+ };
+ const harness = new Script(`${treeSource}\n${previewSource}\n({ appendPreviewNode, previewValue })`).runInNewContext({
+ describeOverlayNode: () => '',
+ document,
+ getElementIdForNode: (node: DebugTreeNode) => node.element?.id ?? null,
+ getNodeAttributes: (node: DebugTreeNode) => node.element?.attributes ?? {},
+ getNodeId: (node: DebugTreeNode) => String(node.id ?? node.element?.id ?? node.tag),
+ isInteractiveNode: () => false,
+ normalizeBounds: (bounds: unknown) => bounds,
+ normalizeLabelValue: String,
+ }) as { appendPreviewNode(node: DebugTreeNode, parent: PreviewElement): void; previewValue(value: unknown): string };
+ return { ...harness, createdElements };
+}
+
+interface PreviewElement {
+ appendChild(child: PreviewElement): void;
+ children: PreviewElement[];
+ classList: { add(...names: string[]): void };
+ dataset: Record;
+ disabled: boolean;
+ draggable: boolean;
+ parent: PreviewElement | null;
+ placeholder: string;
+ readOnly: boolean;
+ scrollLeft: number;
+ scrollTop: number;
+ style: Record;
+ tagName: string;
+ textContent: string;
+ title: string;
+ type: string;
+ value: string;
+}
+
+function createPreviewElement(tag: string): PreviewElement {
+ const element: PreviewElement = {
+ appendChild: child => {
+ element.children.push(child);
+ child.parent = element;
+ },
+ children: [],
+ classList: { add: () => {} },
+ dataset: {},
+ disabled: false,
+ draggable: false,
+ parent: null,
+ placeholder: '',
+ readOnly: false,
+ scrollLeft: 0,
+ scrollTop: 0,
+ style: {},
+ tagName: tag.toUpperCase(),
+ textContent: '',
+ title: '',
+ type: '',
+ value: '',
+ };
+ return element;
+}
+
+function loadFirstElementDescendant(): (node: DebugTreeNode) => DebugTreeNode | null {
+ const treeSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8');
+ const runtimeSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-runtime.js'), 'utf8');
+ return new Script(`${treeSource}\n${runtimeSource}\nfirstElementDescendant`).runInNewContext() as (
+ node: DebugTreeNode,
+ ) => DebugTreeNode | null;
+}
+
+function loadRawInspectorSerializer(): (node: DebugTreeNode, geometry: unknown, target: unknown) => string {
+ const treeSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8');
+ const renderSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-render.js'), 'utf8');
+ return new Script(`${treeSource}\n${renderSource}\nserializeRawInspectorNode`).runInNewContext() as (
+ node: DebugTreeNode,
+ geometry: unknown,
+ target: unknown,
+ ) => string;
+}
+
+function loadStandaloneMetadataHarness(): {
+ nodeMatchesSearch(node: DebugTreeNode, search: string): boolean;
+ payloadToDisplayString(payload: unknown): string;
+ renderAttributesTable(attributes: Record): string;
+} {
+ const treeSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8');
+ const renderSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-render.js'), 'utf8');
+ return new Script(
+ `${treeSource}\n${renderSource}\n({ nodeMatchesSearch, payloadToDisplayString, renderAttributesTable })`,
+ ).runInNewContext({
+ escapeHtml: String,
+ getNodeAttributes: (node: DebugTreeNode) => node.element?.attributes ?? {},
+ getNodeId: (node: DebugTreeNode) => String(node.id ?? node.tag),
+ }) as {
+ nodeMatchesSearch(node: DebugTreeNode, search: string): boolean;
+ payloadToDisplayString(payload: unknown): string;
+ renderAttributesTable(attributes: Record): string;
+ };
+}
+
+function loadPreviewSnapshotSerializer(snapshot: Record): () => string {
+ const treeSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8');
+ const runtimeSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-runtime.js'), 'utf8');
+ return new Script(`${treeSource}\n${runtimeSource}\npreviewSnapshotProjectionJson`).runInNewContext({
+ state: { snapshot },
+ }) as () => string;
+}
+
+function makeDeepMetadata(depth: number): Record {
+ const root: Record = { needle: 'metadata-needle' };
+ let current = root;
+ for (let index = 0; index < depth; index += 1) {
+ const child: Record = { index };
+ current['next'] = child;
+ current = child;
+ }
+ current['cycle'] = root;
+ return root;
+}
+
+function makeDeepTree(depth: number): { deepest: DebugTreeNode; root: DebugTreeNode } {
+ const root: DebugTreeNode = { element: { id: 0 }, id: 0, tag: 'root' };
+ let current = root;
+ for (let index = 1; index <= depth; index += 1) {
+ const child: DebugTreeNode = { element: { id: index }, id: index, tag: 'node' };
+ current.children = [child];
+ current = child;
+ }
+ return { deepest: current, root };
+}
+
+describe('shared debugger tree model', () => {
+ let model: DebuggerTreeModel;
+
+ beforeEach(() => {
+ const source = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8');
+ model = new Script(`${source}\nvaldiDebuggerTreeModel`).runInNewContext() as DebuggerTreeModel;
+ });
+
+ it('uses the same stable identities for runtime, element, and keyed nodes', () => {
+ expect(model.id({ id: 12, tag: 'view' })).toBe('12');
+ expect(model.id({ element: { id: 'element-4' }, tag: 'label' })).toBe('element-4');
+ expect(model.id({ key: 'title', tag: 'label' })).toBe('label:title');
+ expect(model.id(null)).toBe('');
+ });
+
+ it('shares depth, parent paths, lookup, and attributes across debugger frontends', () => {
+ const label: DebugTreeNode = { element: { attributes: { value: 'Hello' } }, id: 3, tag: 'label' };
+ const container: DebugTreeNode = { children: [label], id: 2, tag: 'view' };
+ const root: DebugTreeNode = { children: [container], id: 1, tag: 'view' };
+ const visited: Array<{ ancestors: string[]; depth: number; id: string }> = [];
+
+ model.walk(
+ root,
+ (node, ancestors, depth) => {
+ visited.push({ ancestors: ancestors.map(ancestor => model.id(ancestor)), depth, id: model.id(node) });
+ },
+ [],
+ 0,
+ );
+
+ expect(visited).toEqual([
+ { ancestors: [], depth: 0, id: '1' },
+ { ancestors: ['1'], depth: 1, id: '2' },
+ { ancestors: ['1', '2'], depth: 2, id: '3' },
+ ]);
+ expect(model.findNode(root, 3)).toBe(label);
+ expect(model.pathToNode(root, 3)).toEqual([root, container, label]);
+ expect(model.attributes(label)).toEqual({ value: 'Hello' });
+ });
+
+ it('returns stable empty values for unavailable targets', () => {
+ expect(model.findNode(null, 'missing')).toBeNull();
+ expect(model.pathToNode(null, 'missing')).toEqual([]);
+ expect(model.attributes(null)).toEqual({});
+ });
+
+ it('walks deep trees iteratively without overflowing the call stack', () => {
+ const root: DebugTreeNode = { id: 0, tag: 'root' };
+ let current = root;
+ const depth = 20_000;
+ for (let index = 1; index <= depth; index += 1) {
+ const child: DebugTreeNode = { id: index, tag: 'node' };
+ current.children = [child];
+ current = child;
+ }
+
+ let visited = 0;
+ expect(
+ model.walk(
+ root,
+ (_node, ancestors, currentDepth) => {
+ expect(ancestors.length).toBe(currentDepth);
+ visited += 1;
+ },
+ [],
+ 0,
+ ),
+ ).toBeTrue();
+ expect(visited).toBe(depth + 1);
+ expect(model.findNode(root, depth)).toBe(current);
+ expect(model.pathToNode(root, depth).length).toBe(depth + 1);
+ });
+
+ it('preserves depth-first render order while visiting cycles and shared nodes once', () => {
+ const shared: DebugTreeNode = { id: 'shared', tag: 'shared' };
+ const first: DebugTreeNode = { children: [shared], id: 'first', tag: 'first' };
+ const second: DebugTreeNode = { children: [shared], id: 'second', tag: 'second' };
+ const root: DebugTreeNode = { children: [first, second], id: 'root', tag: 'root' };
+ shared.children = [root];
+ const visited: Array<{ ancestors: string[]; id: string }> = [];
+
+ model.walk(
+ root,
+ (node, ancestors) => {
+ visited.push({ ancestors: ancestors.map(ancestor => model.id(ancestor)), id: model.id(node) });
+ },
+ [],
+ 0,
+ );
+
+ expect(visited).toEqual([
+ { ancestors: [], id: 'root' },
+ { ancestors: ['root'], id: 'first' },
+ { ancestors: ['root', 'first'], id: 'shared' },
+ { ancestors: ['root'], id: 'second' },
+ ]);
+ });
+
+ it('assigns a shared node to the first parent actually reached in preorder', () => {
+ const shared: DebugTreeNode = { id: 'shared', tag: 'shared' };
+ const first: DebugTreeNode = { children: [shared], id: 'first', tag: 'first' };
+ const root: DebugTreeNode = { children: [first, shared], id: 'root', tag: 'root' };
+ const visited: Array<{ ancestors: string[]; depth: number; id: string }> = [];
+
+ model.walk(
+ root,
+ (node, ancestors, depth) => {
+ visited.push({ ancestors: ancestors.map(ancestor => model.id(ancestor)), depth, id: model.id(node) });
+ },
+ [],
+ 0,
+ );
+ const projection = model.projectTree(root);
+
+ expect(visited).toEqual([
+ { ancestors: [], depth: 0, id: 'root' },
+ { ancestors: ['root'], depth: 1, id: 'first' },
+ { ancestors: ['root', 'first'], depth: 2, id: 'shared' },
+ ]);
+ expect(model.pathToNode(root, 'shared')).toEqual([root, first, shared]);
+ expect(projection.nodes[0]?.childIndexes).toEqual([1]);
+ expect(projection.nodes[1]?.childIndexes).toEqual([2]);
+ expect(projection.nodes[2]).toEqual(jasmine.objectContaining({ depth: 2, parentIndex: 1 }));
+
+ const panel = loadDevToolsPanel();
+ panel.state.snapshot = { tree: root };
+ panel.state.expandedNodeIds = new Set(['root', 'first']);
+ panel.state.selectedNodeId = 'shared';
+ panel.renderTree();
+ expect(panel.elements.tree.innerHTML).toContain('data-node-id="shared" role="treeitem" aria-level="3"');
+ });
+
+ it('stops walking as soon as a visitor, find, or path lookup succeeds', () => {
+ const target: DebugTreeNode = { id: 'target', tag: 'target' };
+ const unreachable: DebugTreeNode = { tag: 'unreachable' };
+ let unreachableIdReads = 0;
+ Object.defineProperty(unreachable, 'id', {
+ get: () => {
+ unreachableIdReads += 1;
+ return 'unreachable';
+ },
+ });
+ const root: DebugTreeNode = { children: [target, unreachable], id: 'root', tag: 'root' };
+ const visited: string[] = [];
+
+ expect(
+ model.walk(
+ root,
+ node => {
+ visited.push(model.id(node));
+ return node !== target;
+ },
+ [],
+ 0,
+ ),
+ ).toBeFalse();
+ expect(visited).toEqual(['root', 'target']);
+ expect(model.findNode(root, 'target')).toBe(target);
+ expect(model.pathToNode(root, 'target')).toEqual([root, target]);
+ expect(unreachableIdReads).toBe(0);
+ });
+
+ it('bounds traversal before an untrusted tree can grow work without limit', () => {
+ const root: DebugTreeNode = {
+ children: Array.from({ length: 26_000 }, (_, index) => ({ id: index + 1, tag: 'child' })),
+ id: 0,
+ tag: 'root',
+ };
+ let visited = 0;
+
+ expect(
+ model.walk(
+ root,
+ () => {
+ visited += 1;
+ },
+ [],
+ 0,
+ ),
+ ).toBeFalse();
+ expect(visited).toBe(25_000);
+ });
+
+ it('decorates, bounds, and computes geometry for a 20k-deep cyclic snapshot iteratively', () => {
+ const root: DebugTreeNode = { id: 0, tag: 'root' };
+ let current = root;
+ const depth = 20_000;
+ for (let index = 1; index <= depth; index += 1) {
+ const child: DebugTreeNode = { id: index, tag: 'node' };
+ current.children = [child];
+ current = child;
+ }
+ current.children = [root];
+ const { model: debuggerModel, state } = loadDebuggerModel();
+ const snapshot: Record = { tree: root };
+
+ expect(() => debuggerModel.decorateSnapshot(snapshot)).not.toThrow();
+ expect(state.geometry?.map.size).toBe(depth + 1);
+ expect(current.element).toBeUndefined();
+ expect((current as DebugTreeNode & { bounds?: unknown }).bounds).toBeDefined();
+ });
+
+ it('renders a 20k-deep cyclic DevTools tree in preorder without recursive overflow', () => {
+ const root: DebugTreeNode = { id: 0, tag: 'root' };
+ let current = root;
+ const depth = 20_000;
+ for (let index = 1; index <= depth; index += 1) {
+ const child: DebugTreeNode = { id: index, tag: 'node' };
+ current.children = [child];
+ current = child;
+ }
+ current.children = [root];
+ const panel = loadDevToolsPanel();
+ panel.state.snapshot = { tree: root };
+ panel.state.search = 'node';
+ panel.state.selectedNodeId = String(depth);
+
+ expect(() => panel.renderTree()).not.toThrow();
+ expect((panel.elements.tree.innerHTML.match(/class="tree-row/g) ?? []).length).toBe(depth + 1);
+ expect(panel.elements.tree.innerHTML.indexOf('data-node-id="0"')).toBeLessThan(
+ panel.elements.tree.innerHTML.indexOf('data-node-id="20000"'),
+ );
+ });
+
+ it('reveals and renders a 20k-deep path linearly without relying on search expansion', () => {
+ const { deepest, root } = makeDeepTree(20_000);
+ const panel = loadDevToolsPanel();
+ panel.state.snapshot = { tree: root };
+ panel.state.search = '';
+ panel.state.selectedNodeId = String(deepest.id);
+
+ const startedAt = performance.now();
+ panel.revealPath(String(deepest.id));
+ panel.renderTree();
+ const elapsedMilliseconds = performance.now() - startedAt;
+
+ expect(panel.state.expandedNodeIds.size).toBe(20_000);
+ expect((panel.elements.tree.innerHTML.match(/class="tree-row/g) ?? []).length).toBe(20_001);
+ expect(elapsedMilliseconds).toBeLessThan(4000);
+ });
+
+ it('renders shared DevTools nodes once at their first preorder position and caps visible rows', () => {
+ const shared: DebugTreeNode = { id: 'shared', tag: 'shared' };
+ const first: DebugTreeNode = { children: [shared], id: 'first', tag: 'first' };
+ const second: DebugTreeNode = { children: [shared], id: 'second', tag: 'second' };
+ const root: DebugTreeNode = { children: [first, second], id: 'root', tag: 'root' };
+ const panel = loadDevToolsPanel();
+ panel.state.snapshot = { tree: root };
+ panel.state.expandedNodeIds = new Set(['root', 'first', 'second']);
+ panel.renderTree();
+
+ const markup = panel.elements.tree.innerHTML;
+ expect((markup.match(/data-node-id="shared"/g) ?? []).length).toBe(1);
+ expect(markup.indexOf('data-node-id="first"')).toBeLessThan(markup.indexOf('data-node-id="shared"'));
+ expect(markup.indexOf('data-node-id="shared"')).toBeLessThan(markup.indexOf('data-node-id="second"'));
+
+ root.children = Array.from({ length: 26_000 }, (_, index) => ({ id: `wide-${index}`, tag: 'node' }));
+ panel.state.expandedNodeIds = new Set(['root']);
+ panel.renderTree();
+ expect((panel.elements.tree.innerHTML.match(/class="tree-row/g) ?? []).length).toBe(25_000);
+ });
+
+ it('builds the HTML preview iteratively for deep cyclic/shared graphs and caps rendered nodes', () => {
+ const { deepest, root } = makeDeepTree(20_000);
+ const firstChild = root.children![0]!;
+ const shared: DebugTreeNode = { element: { id: 'shared' }, id: 'shared', tag: 'shared' };
+ root.children = [firstChild, shared];
+ deepest.children = [shared, root];
+ shared.children = [root];
+ const preview = loadPreviewAppender();
+ const previewRoot = createPreviewElement('main');
+
+ expect(() => preview.appendPreviewNode(root, previewRoot)).not.toThrow();
+ expect(preview.createdElements.length).toBe(20_002);
+ expect(preview.createdElements.filter(element => element.dataset['previewNodeId'] === 'shared').length).toBe(1);
+
+ const cappedRoot: DebugTreeNode = {
+ children: Array.from({ length: 26_000 }, (_, index) => ({ element: { id: index }, id: index, tag: 'node' })),
+ element: { id: 'capped-root' },
+ id: 'capped-root',
+ tag: 'root',
+ };
+ const cappedPreview = loadPreviewAppender();
+ cappedPreview.appendPreviewNode(cappedRoot, createPreviewElement('main'));
+ expect(cappedPreview.createdElements.length).toBe(25_000);
+ });
+
+ it('finds element descendants iteratively through deep cyclic/shared graphs and respects the cap', () => {
+ const findFirstElement = loadFirstElementDescendant();
+ const root: DebugTreeNode = { id: 0, tag: 'root' };
+ let current = root;
+ for (let index = 1; index <= 20_000; index += 1) {
+ const child: DebugTreeNode = { id: index, tag: 'node' };
+ current.children = [child];
+ current = child;
+ }
+ const shared: DebugTreeNode = { element: { id: 'target' }, id: 'shared', tag: 'shared' };
+ current.children = [root, shared];
+ shared.children = [root];
+ expect(findFirstElement(root)).toBe(shared);
+
+ const first: DebugTreeNode = { children: [shared], id: 'first', tag: 'first' };
+ const second: DebugTreeNode = { children: [shared], id: 'second', tag: 'second' };
+ expect(findFirstElement({ children: [first, second], id: 'shared-root', tag: 'root' })).toBe(shared);
+
+ const cappedRoot: DebugTreeNode = {
+ children: Array.from({ length: 25_000 }, (_, index) =>
+ index === 24_999
+ ? { element: { id: 'past-cap' }, id: 'past-cap', tag: 'target' }
+ : { id: `node-${index}`, tag: 'node' },
+ ),
+ id: 'capped-root',
+ tag: 'root',
+ };
+ expect(findFirstElement(cappedRoot)).toBeNull();
+ });
+
+ it('copies a flat bounded projection for deep cyclic/shared selected nodes', () => {
+ const { deepest, root } = makeDeepTree(20_000);
+ const firstChild = root.children![0]!;
+ const shared: DebugTreeNode = { element: { id: 'shared' }, id: 'shared', tag: 'shared' };
+ root.children = [firstChild, shared];
+ deepest.children = [shared, root];
+ shared.children = [root];
+ const panel = loadDevToolsPanel();
+ panel.state.snapshot = { tree: root };
+ panel.state.selectedNodeId = '0';
+
+ const projection = JSON.parse(panel.selectedNodeProjectionJson()!) as {
+ complete: boolean;
+ nodeCount: number;
+ nodes: Array<{ childIndexes: number[]; data: { id: number | string } }>;
+ };
+ expect(projection.complete).toBeTrue();
+ expect(projection.nodeCount).toBe(20_002);
+ expect(projection.nodes.filter(node => node.data.id === 'shared').length).toBe(1);
+
+ const cappedRoot: DebugTreeNode = {
+ children: Array.from({ length: 26_000 }, (_, index) => ({ id: index, tag: 'node' })),
+ id: 'capped-root',
+ tag: 'root',
+ };
+ panel.state.snapshot = { tree: cappedRoot };
+ panel.state.selectedNodeId = 'capped-root';
+ const cappedProjection = JSON.parse(panel.selectedNodeProjectionJson()!) as {
+ complete: boolean;
+ nodeCount: number;
+ };
+ expect(cappedProjection.complete).toBeFalse();
+ expect(cappedProjection.nodeCount).toBe(25_000);
+ });
+
+ it('serializes raw inspector data as a flat bounded projection for deep cyclic/shared nodes', () => {
+ const { deepest, root } = makeDeepTree(20_000);
+ const firstChild = root.children![0]!;
+ const shared: DebugTreeNode = { element: { id: 'shared' }, id: 'shared', tag: 'shared' };
+ root.children = [firstChild, shared];
+ deepest.children = [shared, root];
+ shared.children = [root];
+ const geometry: Record = { bounds: { height: 10, width: 20 } };
+ geometry['self'] = geometry;
+ const target: Record = { geometry };
+ const serialize = loadRawInspectorSerializer();
+
+ const payload = JSON.parse(serialize(root, geometry, target)) as {
+ node: { complete: boolean; nodeCount: number; nodes: Array<{ data: { id: number | string } }> };
+ projectionComplete: boolean;
+ };
+ expect(payload.projectionComplete).toBeTrue();
+ expect(payload.node.nodeCount).toBe(20_002);
+ expect(payload.node.nodes.filter(node => node.data.id === 'shared').length).toBe(1);
+
+ const cappedRoot: DebugTreeNode = {
+ children: Array.from({ length: 26_000 }, (_, index) => ({ id: index, tag: 'node' })),
+ id: 'capped-root',
+ tag: 'root',
+ };
+ const cappedPayload = JSON.parse(serialize(cappedRoot, {}, {})) as {
+ node: { complete: boolean; nodeCount: number };
+ projectionComplete: boolean;
+ };
+ expect(cappedPayload.node.complete).toBeFalse();
+ expect(cappedPayload.node.nodeCount).toBe(25_000);
+ expect(cappedPayload.projectionComplete).toBeFalse();
+ });
+
+ it('routes every standalone and DevTools metadata surface through bounded cycle-safe projection', () => {
+ const metadata = makeDeepMetadata(20_000);
+ metadata['oversized'] = Array.from({ length: 260_000 }, (_, index) => index);
+ const node: DebugTreeNode = {
+ element: { attributes: { metadata } },
+ id: 'metadata-node',
+ tag: 'view',
+ };
+ const { model: debuggerModel } = loadDebuggerModel();
+ const standalone = loadStandaloneMetadataHarness();
+ const panel = loadDevToolsPanel();
+
+ expect(() => debuggerModel.normalizeLabelValue(metadata)).not.toThrow();
+ expect(() => standalone.nodeMatchesSearch(node, 'metadata-needle')).not.toThrow();
+ expect(standalone.nodeMatchesSearch(node, 'metadata-needle')).toBeTrue();
+ expect(() => standalone.payloadToDisplayString(metadata)).not.toThrow();
+ expect(() => panel.visibleSearchIds(node, 'metadata-needle')).not.toThrow();
+ expect(Array.from(panel.visibleSearchIds(node, 'metadata-needle'))).toEqual(['metadata-node']);
+ const renderedValue = panel.renderValue(metadata);
+ expect(renderedValue.length).toBeLessThan(500);
+
+ const { deepest, root } = makeDeepTree(20_000);
+ deepest.children = [root];
+ const serializePreview = loadPreviewSnapshotSerializer({ metadata, tree: root });
+ const previewProjection = JSON.parse(serializePreview()) as {
+ projectionComplete: boolean;
+ tree: { nodeCount: number };
+ };
+ expect(previewProjection.projectionComplete).toBeFalse();
+ expect(previewProjection.tree.nodeCount).toBe(20_001);
+ });
+
+ it('caps direct strings at every label, preview, payload, and attribute surface', () => {
+ const oversized = 'x'.repeat(60_000);
+ const projection = model.projectValue(oversized);
+ const { model: debuggerModel } = loadDebuggerModel();
+ const preview = loadPreviewAppender();
+ const standalone = loadStandaloneMetadataHarness();
+ const panel = loadDevToolsPanel();
+
+ expect(projection.complete).toBeFalse();
+ expect((projection.value as string).length).toBe(50_000);
+ expect((projection.value as string).endsWith('…[truncated]')).toBeTrue();
+ expect(debuggerModel.normalizeLabelValue(oversized).length).toBe(50_000);
+ expect(preview.previewValue(oversized).length).toBe(50_000);
+ preview.appendPreviewNode(
+ { element: { attributes: { value: oversized }, id: 'oversized-label' }, id: 'oversized-label', tag: 'label' },
+ createPreviewElement('main'),
+ );
+ expect(preview.createdElements[0]?.textContent.length).toBe(50_000);
+ expect(standalone.payloadToDisplayString(oversized).length).toBe(50_000);
+ const attributes = standalone.renderAttributesTable({ title: oversized });
+ expect(attributes.length).toBeLessThan(50_100);
+ expect(attributes).toContain('…[truncated]');
+ expect(panel.renderValue(oversized).length).toBeLessThan(400);
+ });
+
+ it('projects sparse arrays and sparse children by bounded descriptors without invoking accessors', () => {
+ const sparse = [] as unknown as unknown[] & Record;
+ sparse[10_000_000] = 'far-value';
+ sparse['custom'] = 'custom-value';
+ let getterCalls = 0;
+ let proxyGets = 0;
+ sparse['proxied'] = new Proxy(
+ { safe: 'proxy-value' },
+ {
+ get: () => {
+ proxyGets += 1;
+ throw new Error('projection must not read through a proxy');
+ },
+ },
+ );
+ Object.defineProperty(sparse, 'accessor', {
+ enumerable: true,
+ get: () => {
+ getterCalls += 1;
+ return 'unsafe';
+ },
+ });
+
+ const startedAt = performance.now();
+ const projection = model.projectValue(sparse);
+ const serialized = JSON.stringify(projection.value);
+ const elapsedMilliseconds = performance.now() - startedAt;
+ const value = projection.value as {
+ $entries: Array>;
+ $length: number;
+ $truncated: string;
+ $type: string;
+ };
+
+ expect(projection.complete).toBeFalse();
+ expect(value.$type).toBe('array');
+ expect(value.$length).toBe(10_000_001);
+ expect(value.$truncated).toBe('sparse-array');
+ expect(value.$entries).toContain(jasmine.objectContaining({ $index: 10_000_000, value: 'far-value' }));
+ expect(value.$entries).toContain(jasmine.objectContaining({ $key: 'custom', value: 'custom-value' }));
+ expect(value.$entries).toContain(
+ jasmine.objectContaining({
+ $key: 'accessor',
+ value: jasmine.objectContaining({ $at: '$.accessor', $truncated: 'accessor' }),
+ }),
+ );
+ expect(getterCalls).toBe(0);
+ expect(proxyGets).toBe(0);
+ expect(serialized.length).toBeLessThan(1000);
+ expect(elapsedMilliseconds).toBeLessThan(1000);
+
+ const sparseChildren: DebugTreeNode[] = [];
+ const distantChild: DebugTreeNode = { id: 'distant', tag: 'child' };
+ sparseChildren[10_000_000] = distantChild;
+ const root: DebugTreeNode = { children: sparseChildren, id: 'root', tag: 'root' };
+ const visited: string[] = [];
+ const sourceIndexes: Array = [];
+ expect(
+ model.walk(
+ root,
+ (node, _ancestors, _depth, sourceChildIndex) => {
+ visited.push(model.id(node));
+ sourceIndexes.push(sourceChildIndex);
+ },
+ [],
+ 0,
+ ),
+ ).toBeFalse();
+ expect(visited).toEqual(['root', 'distant']);
+ expect(sourceIndexes).toEqual([null, 10_000_000]);
+ const treeProjection = model.projectTree(root);
+ expect(treeProjection.complete).toBeFalse();
+ expect(treeProjection.nodes[1]?.sourceChildIndex).toBe(10_000_000);
+ });
+
+ it('preserves array truncation reason and location in serialized projection', () => {
+ const projection = model.projectValue(Array.from({ length: 250_001 }, () => 'value'));
+ const values = projection.value as Array | string>;
+ const marker = values.at(-1) as Record;
+
+ expect(projection.complete).toBeFalse();
+ expect(marker['$truncated']).toBe('value-limit');
+ expect(marker['$at']).toBe('$');
+ const serializedValues = JSON.parse(JSON.stringify(values)) as Array | string>;
+ expect(serializedValues.at(-1)).toEqual(marker);
+ });
+
+ it('preserves own prototype-named keys without inheriting source sentinels in values or tree data', () => {
+ const source = JSON.parse('{"safe":1,"__proto__":{"polluted":true}}') as Record;
+ Object.setPrototypeOf(source, { inheritedSentinel: 'must-not-project' });
+
+ const valueProjection = model.projectValue(source);
+ const projectedValue = valueProjection.value as Record;
+ const serializedValue = JSON.parse(JSON.stringify(projectedValue)) as Record;
+
+ expect(valueProjection.complete).toBeTrue();
+ expect(Object.getPrototypeOf(projectedValue)).toBeNull();
+ expect(Object.prototype.hasOwnProperty.call(projectedValue, '__proto__')).toBeTrue();
+ expect(projectedValue['inheritedSentinel']).toBeUndefined();
+ expect((projectedValue['__proto__'] as Record)['polluted']).toBeTrue();
+ expect(serializedValue['__proto__']).toEqual({ polluted: true });
+
+ const root = JSON.parse('{"id":"prototype-node","tag":"view","__proto__":{"treePolluted":true}}') as DebugTreeNode;
+ Object.setPrototypeOf(root, { inheritedSentinel: 'must-not-project' });
+ const treeProjection = model.projectTree(root);
+ const data = treeProjection.nodes[0]?.data;
+ const serializedTree = JSON.parse(JSON.stringify(treeProjection)) as {
+ nodes: Array<{ data: Record }>;
+ };
+
+ expect(treeProjection.complete).toBeTrue();
+ expect(Object.getPrototypeOf(data)).toBeNull();
+ expect(Object.prototype.hasOwnProperty.call(data, '__proto__')).toBeTrue();
+ expect(data?.['inheritedSentinel']).toBeUndefined();
+ expect(serializedTree.nodes[0]?.data['__proto__']).toEqual({
+ treePolluted: true,
+ });
+ });
+
+ it('restores a server flat tree iteratively without changing hierarchy behavior', () => {
+ const { deepest, root } = makeDeepTree(20_000);
+ deepest.children = [root];
+ const projection = model.projectTree(root);
+
+ const restored = model.restoreTree(projection);
+
+ expect(restored).not.toBeNull();
+ expect(model.findNode(restored, 20_000)?.id).toBe(20_000);
+ expect(model.pathToNode(restored, 20_000).length).toBe(20_001);
+ const { model: debuggerModel, state } = loadDebuggerModel();
+ expect(() => debuggerModel.decorateSnapshot({ tree: projection })).not.toThrow();
+ expect(state.geometry?.map.size).toBe(20_001);
+ });
+});
diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts
index 3dbd13343..f41feac4a 100644
--- a/npm_modules/cli/src/debugger/server.spec.ts
+++ b/npm_modules/cli/src/debugger/server.spec.ts
@@ -4,8 +4,10 @@ import * as http from 'node:http';
import * as net from 'node:net';
import * as os from 'node:os';
import * as path from 'node:path';
+import { valdiDebugger } from '../commands/debugger';
+import { ArgumentsResolver } from '../utils/ArgumentsResolver';
import type { DebuggerServerInfo } from './server';
-import { startDebuggerServer } from './server';
+import { projectDebuggerTreeForJson, startDebuggerServer } from './server';
interface HttpResult {
body: string;
@@ -31,6 +33,7 @@ const GET_REQUEST_OPTIONS: HttpRequestOptions = {
headers: {},
body: undefined,
};
+const WEB_PREVIEW_NONCE = 'server-devtools-nonce-123456';
async function getFreePort(): Promise {
return await new Promise((resolve, reject) => {
@@ -197,6 +200,146 @@ describe('debugger server', () => {
fs.rmSync(assetRoot, { recursive: true, force: true });
});
+ it('projects deep cyclic daemon and Owl hierarchies before the HTTP JSON boundary', () => {
+ const root: Record = { id: 0, tag: 'root' };
+ let current = root;
+ for (let index = 1; index <= 20_000; index += 1) {
+ const child: Record = { id: index, tag: 'node' };
+ current['children'] = [child];
+ current = child;
+ }
+ const cyclicMetadata: Record = { title: 'cyclic' };
+ cyclicMetadata['self'] = cyclicMetadata;
+ current['metadata'] = cyclicMetadata;
+ current['children'] = [root];
+
+ const projection = projectDebuggerTreeForJson(root);
+
+ expect(projection.nodeCount).toBe(20_001);
+ expect(projection.complete).toBeTrue();
+ expect(() => JSON.stringify({ tree: projection })).not.toThrow();
+ });
+
+ it('assigns shared hierarchy ownership to the first node reached in preorder and caps output', () => {
+ const shared = { id: 'shared', tag: 'shared' };
+ const first = { children: [shared], id: 'first', tag: 'first' };
+ const root: Record = { children: [first, shared], id: 'root', tag: 'root' };
+
+ const projection = projectDebuggerTreeForJson(root);
+
+ expect(projection.nodes.map(node => node.data['id'])).toEqual(['root', 'first', 'shared']);
+ expect(projection.nodes[0]?.childIndexes).toEqual([1]);
+ expect(projection.nodes[1]?.childIndexes).toEqual([2]);
+ expect(projection.nodes[2]).toEqual(jasmine.objectContaining({ depth: 2, parentIndex: 1 }));
+
+ root['children'] = Array.from({ length: 26_000 }, (_, index) => ({ id: index, tag: 'node' }));
+ const capped = projectDebuggerTreeForJson(root);
+ expect(capped.nodeCount).toBe(25_000);
+ expect(capped.complete).toBeFalse();
+ expect(() => JSON.stringify({ tree: capped })).not.toThrow();
+ });
+
+ it('bounds sparse metadata and child arrays without invoking accessors at the HTTP boundary', () => {
+ const sparseMetadata: unknown[] = [];
+ sparseMetadata[10_000_000] = 'far-value';
+ let getterCalls = 0;
+ Object.defineProperty(sparseMetadata, 'accessor', {
+ enumerable: true,
+ get: () => {
+ getterCalls += 1;
+ return 'unsafe';
+ },
+ });
+ const sparseChildren: Array> = [];
+ sparseChildren[10_000_000] = { id: 'distant', tag: 'child' };
+ const root: Record = {
+ children: sparseChildren,
+ id: 'root',
+ metadata: sparseMetadata,
+ oversized: 'x'.repeat(60_000),
+ tag: 'root',
+ };
+
+ const startedAt = performance.now();
+ const projection = projectDebuggerTreeForJson(root);
+ const elapsedMilliseconds = performance.now() - startedAt;
+ const metadata = projection.nodes[0]?.data['metadata'] as {
+ $entries: Array>;
+ $length: number;
+ $truncated: string;
+ };
+ const serializedMetadata = JSON.stringify(metadata);
+
+ expect(projection.complete).toBeFalse();
+ expect(projection.nodeCount).toBe(2);
+ expect(projection.nodes[1]?.sourceChildIndex).toBe(10_000_000);
+ expect(metadata.$length).toBe(10_000_001);
+ expect(metadata.$truncated).toBe('sparse-array');
+ expect((projection.nodes[0]?.data['oversized'] as string).length).toBe(50_000);
+ expect(metadata.$entries).toContain(jasmine.objectContaining({ $index: 10_000_000, value: 'far-value' }));
+ expect(metadata.$entries).toContain(
+ jasmine.objectContaining({
+ $key: 'accessor',
+ value: jasmine.objectContaining({ $truncated: 'accessor' }),
+ }),
+ );
+ expect(getterCalls).toBe(0);
+ expect(serializedMetadata.length).toBeLessThan(1500);
+ expect(elapsedMilliseconds).toBeLessThan(1000);
+ });
+
+ it('preserves own prototype-named keys in projected values and tree-node data', () => {
+ const metadata = JSON.parse('{"__proto__":{"metadataPolluted":true},"safe":1}') as Record;
+ Object.setPrototypeOf(metadata, { inheritedSentinel: 'must-not-project' });
+ const root = JSON.parse('{"id":"prototype-node","tag":"view","__proto__":{"treePolluted":true}}') as Record<
+ string,
+ unknown
+ >;
+ Object.setPrototypeOf(root, { inheritedSentinel: 'must-not-project' });
+ root['metadata'] = metadata;
+
+ const projection = projectDebuggerTreeForJson(root);
+ const data = projection.nodes[0]?.data;
+ const projectedMetadata = data?.['metadata'] as Record;
+ const serialized = JSON.parse(JSON.stringify(projection)) as {
+ nodes: Array<{ data: Record }>;
+ };
+
+ expect(projection.complete).toBeTrue();
+ expect(Object.getPrototypeOf(data)).toBeNull();
+ expect(Object.getPrototypeOf(projectedMetadata)).toBeNull();
+ expect(Object.prototype.hasOwnProperty.call(data, '__proto__')).toBeTrue();
+ expect(Object.prototype.hasOwnProperty.call(projectedMetadata, '__proto__')).toBeTrue();
+ expect(data?.['inheritedSentinel']).toBeUndefined();
+ expect(projectedMetadata['inheritedSentinel']).toBeUndefined();
+ expect(serialized.nodes[0]?.data['__proto__']).toEqual({ treePolluted: true });
+ expect((serialized.nodes[0]?.data['metadata'] as Record)['__proto__']).toEqual({
+ metadataPolluted: true,
+ });
+ });
+
+ it('marks a revoked proxy child incomplete without throwing or invoking its traps', () => {
+ const revokedChild = Proxy.revocable({ id: 'revoked', tag: 'view' }, {});
+ revokedChild.revoke();
+ const root: Record = {
+ children: [revokedChild.proxy],
+ id: 'root',
+ tag: 'view',
+ };
+
+ const projection = projectDebuggerTreeForJson(root);
+
+ expect(projection.complete).toBeFalse();
+ expect(projection.nodeCount).toBe(1);
+ expect(projection.truncations).toContain(
+ jasmine.objectContaining({
+ $at: '$.nodes[0].children[0]',
+ $truncated: 'unavailable-child',
+ }),
+ );
+ expect(() => JSON.stringify(projection)).not.toThrow();
+ });
+
it('serves the packaged debugger application', async () => {
debuggerServer = await startDebuggerServer({
assetRoot,
@@ -215,6 +358,211 @@ describe('debugger server', () => {
expect(result.xFrameOptions).toBe('DENY');
});
+ it('allows extension framing only for the dedicated DevTools panel route', async () => {
+ fs.writeFileSync(path.join(assetRoot, 'devtools-panel.html'), 'Valdi DevTools');
+ fs.writeFileSync(path.join(assetRoot, 'devtools-panel.js'), 'void 0;');
+ debuggerServer = await startDebuggerServer({
+ assetRoot,
+ host: '127.0.0.1',
+ port: await getFreePort(),
+ strictPort: true,
+ });
+
+ const panel = await request(new URL('/devtools-panel.html', debuggerServer.url).toString(), GET_REQUEST_OPTIONS);
+ const standalone = await request(debuggerServer.url, GET_REQUEST_OPTIONS);
+ const panelScript = await request(
+ new URL('/devtools-panel.js', debuggerServer.url).toString(),
+ GET_REQUEST_OPTIONS,
+ );
+
+ expect(panel.statusCode).toBe(200);
+ expect(panel.contentSecurityPolicy).toContain('frame-ancestors chrome-extension://*');
+ expect(panel.contentSecurityPolicy).not.toContain("frame-ancestors 'none'");
+ expect(panel.xFrameOptions).toBe('');
+ expect(standalone.contentSecurityPolicy).toContain("frame-ancestors 'none'");
+ expect(standalone.xFrameOptions).toBe('DENY');
+ expect(panelScript.contentSecurityPolicy).toContain("frame-ancestors 'none'");
+ expect(panelScript.xFrameOptions).toBe('DENY');
+ });
+
+ it('resolves only the exact configured web preview page for integrated DevTools', async () => {
+ debuggerServer = await startDebuggerServer({
+ assetRoot,
+ host: '127.0.0.1',
+ port: await getFreePort(),
+ strictPort: true,
+ webPreviewUrl: 'http://127.0.0.1:54321/index.html?tenant=alpha&mode=dev',
+ chromiumDebuggingPort: 9333,
+ });
+
+ const matching = await request(
+ new URL(
+ `/api/devtools/target?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDebugger%3D1%26mode%3Ddev%26valdiDevTools%3D1%26tenant%3Dalpha&targetNonce=${WEB_PREVIEW_NONCE}`,
+ debuggerServer.url,
+ ).toString(),
+ GET_REQUEST_OPTIONS,
+ );
+ const differentPath = await request(
+ new URL(
+ `/api/devtools/target?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Fother.html&targetNonce=${WEB_PREVIEW_NONCE}`,
+ debuggerServer.url,
+ ).toString(),
+ GET_REQUEST_OPTIONS,
+ );
+ const differentQuery = await request(
+ new URL(
+ `/api/devtools/target?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3Fmode%3Ddev%26tenant%3Dbeta%26valdiDebugger%3D1%26valdiDevTools%3D1&targetNonce=${WEB_PREVIEW_NONCE}`,
+ debuggerServer.url,
+ ).toString(),
+ GET_REQUEST_OPTIONS,
+ );
+
+ expect(matching.statusCode).toBe(200);
+ expect(JSON.parse(matching.body)).toEqual({
+ target: jasmine.objectContaining({
+ applicationUrl: 'http://127.0.0.1:54321/index.html?tenant=alpha&mode=dev',
+ debuggingPort: 9333,
+ id: 'owl:web-preview',
+ sessionId: 'web-preview',
+ }),
+ });
+ expect(differentPath.statusCode).toBe(404);
+ expect(JSON.parse(differentPath.body)).toEqual({
+ error: 'The inspected page does not match the configured Valdi web preview target.',
+ });
+ expect(differentQuery.statusCode).toBe(404);
+ expect(JSON.parse(differentQuery.body)).toEqual({
+ error: 'The inspected page does not match the configured Valdi web preview target.',
+ });
+ });
+
+ it('requires an inspected-tab nonce when resolving the integrated DevTools target', async () => {
+ debuggerServer = await startDebuggerServer({
+ assetRoot,
+ host: '127.0.0.1',
+ port: await getFreePort(),
+ strictPort: true,
+ webPreviewUrl: 'http://127.0.0.1:54321/index.html',
+ });
+
+ const result = await request(
+ new URL(
+ '/api/devtools/target?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html',
+ debuggerServer.url,
+ ).toString(),
+ GET_REQUEST_OPTIONS,
+ );
+
+ expect(result.statusCode).toBe(400);
+ expect(JSON.parse(result.body)).toEqual({
+ error: 'DevTools target discovery requires a valid inspected-tab nonce.',
+ });
+ });
+
+ it('requires JSON for executable integrated DevTools routes', async () => {
+ debuggerServer = await startDebuggerServer({
+ assetRoot,
+ host: '127.0.0.1',
+ port: await getFreePort(),
+ strictPort: true,
+ webPreviewUrl: 'http://127.0.0.1:54321/index.html',
+ });
+
+ const plainText = await request(new URL('/api/devtools/evaluate', debuggerServer.url).toString(), {
+ method: 'POST',
+ headers: { 'Content-Type': 'text/plain' },
+ body: '{"sessionId":"web-preview","expression":"1 + 1"}',
+ });
+ const json = await request(new URL('/api/devtools/evaluate', debuggerServer.url).toString(), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{"sessionId":"missing","expression":"1 + 1"}',
+ });
+
+ expect(plainText.statusCode).toBe(415);
+ expect(JSON.parse(plainText.body)).toEqual({
+ error: 'Valdi DevTools actions require an application/json request.',
+ });
+ expect(json.statusCode).toBe(404);
+ expect(JSON.parse(json.body)).toEqual({
+ error: 'The configured web preview debugger target is not available.',
+ });
+ });
+
+ it('does not accept mutations on read-only integrated DevTools routes', async () => {
+ debuggerServer = await startDebuggerServer({
+ assetRoot,
+ host: '127.0.0.1',
+ port: await getFreePort(),
+ strictPort: true,
+ webPreviewUrl: 'http://127.0.0.1:54321/index.html',
+ });
+
+ const result = await request(new URL('/api/devtools/target', debuggerServer.url).toString(), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{}',
+ });
+
+ expect(result.statusCode).toBe(405);
+ expect(JSON.parse(result.body)).toEqual({ error: 'Valdi DevTools target discovery requires GET.' });
+ });
+
+ it('rejects remote web previews and invalid Chromium debugging ports', async () => {
+ await expectAsync(
+ startDebuggerServer({
+ assetRoot,
+ host: '127.0.0.1',
+ port: await getFreePort(),
+ strictPort: true,
+ webPreviewUrl: 'https://example.com/index.html',
+ }),
+ ).toBeRejectedWithError(/unauthenticated loopback HTTP URL/);
+ await expectAsync(
+ startDebuggerServer({
+ assetRoot,
+ host: '127.0.0.1',
+ port: await getFreePort(),
+ strictPort: true,
+ webPreviewUrl: 'http://127.0.0.1:54321/index.html',
+ chromiumDebuggingPort: 0,
+ }),
+ ).toBeRejectedWithError(/Chromium debugging port must be an integer between 1 and 65535/);
+ });
+
+ it('restores the active web preview target when a configured server cannot bind', async () => {
+ debuggerServer = await startDebuggerServer({
+ assetRoot,
+ host: '127.0.0.1',
+ port: await getFreePort(),
+ strictPort: true,
+ });
+ const occupiedPort = await getFreePort();
+ occupiedPortServer = await listenOnPort(occupiedPort);
+
+ await expectAsync(
+ startDebuggerServer({
+ assetRoot,
+ host: '127.0.0.1',
+ port: occupiedPort,
+ strictPort: true,
+ webPreviewUrl: 'http://127.0.0.1:54321/index.html',
+ }),
+ ).toBeRejected();
+
+ const result = await request(
+ new URL(
+ '/api/devtools/target?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html',
+ debuggerServer.url,
+ ).toString(),
+ GET_REQUEST_OPTIONS,
+ );
+ expect(result.statusCode).toBe(404);
+ expect(JSON.parse(result.body)).toEqual({
+ error: 'Start valdi debugger with --web-preview-url before opening the DevTools panel.',
+ });
+ });
+
it('closes cleanly while an event stream is open', async () => {
const serverToClose = await startDebuggerServer({
assetRoot,
@@ -544,6 +892,26 @@ describe('debugger server', () => {
expect((JSON.parse(result.body) as { source: string }).source).toBe('café');
});
+ it('rejects malformed UTF-8 split across executable JSON request chunks', async () => {
+ debuggerServer = await startDebuggerServer({
+ assetRoot,
+ host: '127.0.0.1',
+ port: await getFreePort(),
+ strictPort: true,
+ });
+ const streaming = startStreamingRequest(new URL('/api/debugger/actions', debuggerServer.url).toString(), 'POST', {
+ 'Content-Type': 'application/json',
+ });
+ streaming.request.write(Buffer.from('{"action":"refreshSnapshot","source":"', 'utf8'));
+ streaming.request.write(Buffer.from([0xc3]));
+ await new Promise(resolve => setImmediate(resolve));
+ streaming.request.end(Buffer.concat([Buffer.from([0x28]), Buffer.from('"}', 'utf8')]));
+ const result = await streaming.result;
+
+ expect(result.statusCode).toBe(400);
+ expect(JSON.parse(result.body)).toEqual({ error: 'Request body must contain valid UTF-8.' });
+ });
+
it('serializes concurrent CPU profile transitions before reading their bodies', async () => {
debuggerServer = await startDebuggerServer({
assetRoot,
@@ -716,4 +1084,69 @@ describe('debugger server', () => {
expect(initialLogs.map(log => log.message)).toEqual(['repeated', 'repeated']);
expect(appendedLogs.map(log => log.message)).toEqual(['repeated']);
});
+
+ it('rejects blank and invalid command preview URLs before allocating server or extension resources', async () => {
+ const extensionDirectoryPrefix = 'valdi-devtools-extension-';
+ const extensionDirectoriesBefore = fs
+ .readdirSync(os.tmpdir())
+ .filter(name => name.startsWith(extensionDirectoryPrefix))
+ .sort();
+ const invalidValues = [
+ { error: /must not be blank/, value: ' \t ' },
+ { error: /Invalid web preview URL/, value: 'not a URL' },
+ ];
+
+ for (const invalidValue of invalidValues) {
+ const port = await getFreePort();
+ await expectAsync(
+ valdiDebugger(
+ new ArgumentsResolver({
+ chromiumDebuggingPort: 9222,
+ host: '127.0.0.1',
+ json: true,
+ port,
+ strictPort: true,
+ webPreviewUrl: invalidValue.value,
+ }),
+ ),
+ ).toBeRejectedWithError(invalidValue.error);
+ const availableServer = await listenOnPort(port);
+ await closeServer(availableServer);
+ }
+
+ expect(
+ fs
+ .readdirSync(os.tmpdir())
+ .filter(name => name.startsWith(extensionDirectoryPrefix))
+ .sort(),
+ ).toEqual(extensionDirectoriesBefore);
+ });
+
+ it('closes the debugger and removes its temporary extension on SIGHUP', async () => {
+ const consoleLog = spyOn(console, 'log');
+ const port = await getFreePort();
+ const previousSignalListeners = new Set(process.listeners('SIGHUP'));
+ const operation = valdiDebugger(
+ new ArgumentsResolver({
+ chromiumDebuggingPort: 9222,
+ host: '127.0.0.1',
+ json: true,
+ port,
+ strictPort: true,
+ webPreviewUrl: 'http://127.0.0.1:54321/index.html',
+ }),
+ );
+ while (consoleLog.calls.count() === 0) await new Promise(resolve => setImmediate(resolve));
+ const startup = JSON.parse(String(consoleLog.calls.mostRecent().args[0])) as { extensionDirectory: string };
+ expect(fs.existsSync(startup.extensionDirectory)).toBeTrue();
+
+ const shutdownListener = process.listeners('SIGHUP').find(listener => !previousSignalListeners.has(listener));
+ if (!shutdownListener) throw new Error('Expected the debugger command to install a SIGHUP listener.');
+ shutdownListener('SIGHUP');
+ await operation;
+
+ expect(fs.existsSync(startup.extensionDirectory)).toBeFalse();
+ const availableServer = await listenOnPort(port);
+ await closeServer(availableServer);
+ });
});
diff --git a/npm_modules/cli/src/debugger/server.ts b/npm_modules/cli/src/debugger/server.ts
index 5ab041361..4d744e122 100644
--- a/npm_modules/cli/src/debugger/server.ts
+++ b/npm_modules/cli/src/debugger/server.ts
@@ -5,6 +5,7 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
import http from 'node:http';
import net from 'node:net';
import path from 'node:path';
+import { TextDecoder } from 'node:util';
import {
type DaemonConnectedClient,
type DaemonConnection,
@@ -15,13 +16,26 @@ import {
} from '../utils/daemonClient';
import { getUserConfig, resolveFilePath } from '../utils/fileUtils';
import { type CpuProfile, HERMES_PORT, HermesConnection, listHermesDevices } from '../utils/hermesClient';
+import { isLoopbackHost, normalizedHostname } from '../utils/loopbackHost';
+import {
+ evaluateOwlApplicationExpression,
+ matchesOwlApplicationUrl,
+ readOwlDebuggerSnapshot,
+} from '../utils/owlCdpClient';
import { DebuggerInputType, sendDebuggerInput, validateDebuggerInputRequest } from './inputClient';
const DEFAULT_HOST = process.env['VALDI_DEBUGGER_HOST'] || '127.0.0.1';
const DEFAULT_PORT = Number.parseInt(process.env['VALDI_DEBUGGER_PORT'] || '8765', 10);
+const DEFAULT_CHROMIUM_DEBUGGING_PORT = Number.parseInt(process.env['VALDI_CHROMIUM_DEBUGGING_PORT'] || '9222', 10);
const HOT_RELOAD_PROXY_PORT = Number.parseInt(process.env['VALDI_HOT_RELOAD_PROXY_PORT'] || '9010', 10);
const PORT_SEARCH_LIMIT = 50;
const MAX_RUNTIME_LOG_READ_BYTES = 1024 * 1024;
+const FATAL_JSON_UTF8_DECODER = new TextDecoder('utf8', { fatal: true });
+const WEB_PREVIEW_NONCE_PATTERN = /^[\w-]{16,128}$/;
+const MAX_DEBUGGER_TREE_NODES = 25_000;
+const MAX_DEBUGGER_PROJECTION_VALUES = 250_000;
+const MAX_DEBUGGER_PROJECTION_DEPTH = 64;
+const MAX_DEBUGGER_PROJECTION_STRING_LENGTH = 50_000;
const MIME_TYPES: Record = {
'.html': 'text/html; charset=utf-8',
@@ -96,6 +110,15 @@ interface DebuggerServerOptions {
strictPort?: boolean;
assetRoot?: string;
logsDirectory?: string;
+ webPreviewUrl?: string;
+ chromiumDebuggingPort?: number;
+}
+
+interface WebPreviewDebuggerTarget {
+ applicationUrl: string;
+ debuggingPort: number;
+ id: string;
+ sessionId: string;
}
class ApiRequestError extends Error {
@@ -147,6 +170,7 @@ let devReloadTimer: NodeJS.Timeout | null = null;
let activeHost = DEFAULT_HOST;
let assetRoot = getDefaultAssetRoot();
let activeLogsDirectory: string | null = null;
+let activeWebPreviewTarget: WebPreviewDebuggerTarget | null = null;
let activeProfileSession: ActiveProfileSession | null = null;
let profileTransitionInProgress = false;
const debuggerUiState = createDebuggerUiState();
@@ -157,19 +181,6 @@ function getDefaultAssetRoot(): string {
return path.resolve(__dirname, '..', '..', 'debugger');
}
-function normalizedHostname(hostname: string): string {
- return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
-}
-
-function isLoopbackHost(host: string): boolean {
- const normalizedHost = normalizedHostname(host);
- return (
- normalizedHost === 'localhost' ||
- normalizedHost === '::1' ||
- (net.isIP(normalizedHost) === 4 && normalizedHost.startsWith('127.'))
- );
-}
-
function hostForUrl(host: string): string {
return net.isIP(normalizedHostname(host)) === 6 ? `[${normalizedHostname(host)}]` : host;
}
@@ -294,6 +305,510 @@ function isValidSnapshotBase64(value: string): boolean {
return true;
}
+interface DebuggerProjectionState {
+ complete: boolean;
+ seen: Map