, connect, refresh, reload, status, snapshot, heap, trace, profile, auto on, auto off, clear.',
);
} else if (command.startsWith('section ')) {
void requestDebuggerAction('setActiveSection', { section: command.slice('section '.length).trim() });
+ } else if (command === 'data') {
+ void requestDebuggerAction('setActiveSection', { section: 'data' });
+ void requestDebuggerAction('refreshDebuggerProviders', getSelectedTargetParams());
+ } else if (command === 'settings') {
+ void requestDebuggerAction('setActiveSection', { section: 'settings' });
+ void requestDebuggerAction('refreshDebugSettings', getSelectedTargetParams());
} else if (command === 'path') {
const path = getPathToNode(state.selectedNodeId)
.map(node => `${node.tag}#${getNodeId(node)}`)
@@ -306,6 +357,8 @@ function runCommand(rawCommand) {
void requestDebuggerAction('captureElementSnapshot');
} else if (command === 'heap') {
void requestDebuggerAction('dumpHeap');
+ } else if (command === 'trace') {
+ void requestDebuggerAction('captureRendererTrace');
} else if (command === 'profile') {
void requestDebuggerAction('captureCpuProfile');
} else if (command === 'auto on') {
diff --git a/npm_modules/cli/debugger/debugger-api.js b/npm_modules/cli/debugger/debugger-api.js
index e9c83bc70..5f38adbb7 100644
--- a/npm_modules/cli/debugger/debugger-api.js
+++ b/npm_modules/cli/debugger/debugger-api.js
@@ -1,4 +1,21 @@
// HTTP helpers and the external debugger action/event bridge.
+const DEBUGGER_API_TOKEN_HEADER = 'X-Valdi-Debugger-Token';
+const DEBUGGER_API_TOKEN_QUERY_PARAMETER = 'valdiDebuggerToken';
+const debuggerApiToken = document.querySelector('meta[name="valdi-debugger-api-token"]')?.content || '';
+
+function debuggerApiHeaders(headers) {
+ return {
+ ...headers,
+ [DEBUGGER_API_TOKEN_HEADER]: debuggerApiToken,
+ };
+}
+
+function debuggerEventSourceUrl(path) {
+ const url = new URL(path, window.location.origin);
+ url.searchParams.set(DEBUGGER_API_TOKEN_QUERY_PARAMETER, debuggerApiToken);
+ return url.toString();
+}
+
async function apiGet(path, params = {}, options = {}) {
const url = new URL(path, window.location.origin);
for (const [key, value] of Object.entries(params)) {
@@ -10,7 +27,11 @@ async function apiGet(path, params = {}, options = {}) {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs || 8000);
try {
- const response = await fetch(url, { cache: 'no-store', signal: controller.signal });
+ const response = await fetch(url, {
+ cache: 'no-store',
+ signal: controller.signal,
+ headers: debuggerApiHeaders({}),
+ });
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Request failed: ${response.status}`);
@@ -41,7 +62,7 @@ async function apiPost(path, params = {}, body = {}, options = {}) {
method: 'POST',
cache: 'no-store',
signal: controller.signal,
- headers: { 'Content-Type': 'application/json' },
+ headers: debuggerApiHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
});
const data = await response.json();
@@ -61,7 +82,7 @@ async function apiPost(path, params = {}, body = {}, options = {}) {
function startDebuggerDevReload() {
if (!window.EventSource || window.location.search.includes('noDevReload=1')) return;
- const source = new EventSource('/api/dev-events');
+ const source = new EventSource(debuggerEventSourceUrl('/api/dev-events'));
let loadedRevision = null;
source.addEventListener('ready', event => {
@@ -84,7 +105,7 @@ function startDebuggerDevReload() {
function startDebuggerActionStream() {
if (!window.EventSource) return;
- const source = new EventSource('/api/debugger/events');
+ const source = new EventSource(debuggerEventSourceUrl('/api/debugger/events'));
state.debuggerEventStream = source;
source.addEventListener('ready', event => {
@@ -142,19 +163,25 @@ function applyDebuggerActionPayload(payload) {
const revision = Number(payload?.state?.revision) || 0;
if (revision && revision <= state.lastDebuggerRevision) return;
if (payload?.action) {
- applyDebuggerAction(payload.action, payload.params || {});
+ applyDebuggerAction(payload.action, payload.params || {}, payload.result);
}
if (revision) state.lastDebuggerRevision = revision;
}
async function requestDebuggerAction(action, params = {}) {
- const shouldApplyLocally = !state.debuggerEventsConnected;
+ const runtimeActions = new Set([
+ 'refreshDebuggerProviders',
+ 'refreshDebugSettings',
+ 'setDebugSetting',
+ 'resetDebugSetting',
+ ]);
+ const shouldApplyLocally = !state.debuggerEventsConnected && !runtimeActions.has(action);
if (shouldApplyLocally) {
applyDebuggerAction(action, params);
}
try {
- await apiPost(
+ const response = await apiPost(
'/api/debugger/actions',
{},
{
@@ -164,11 +191,18 @@ async function requestDebuggerAction(action, params = {}) {
},
{ timeoutMs: 5000 },
);
+ const revision = Number(response?.state?.revision) || 0;
+ if (response?.result && (!revision || revision > state.lastDebuggerRevision)) {
+ applyDebuggerAction(action, params, response.result);
+ if (revision) state.lastDebuggerRevision = revision;
+ }
+ return response;
} catch (error) {
if (!shouldApplyLocally) {
applyDebuggerAction(action, params);
}
addLog('warn', 'debugger', `Debugger action bus unavailable; applied ${action} locally. ${error.message}`);
+ return null;
}
}
diff --git a/npm_modules/cli/debugger/debugger-bootstrap.js b/npm_modules/cli/debugger/debugger-bootstrap.js
index 90a7ae974..f78f1210a 100644
--- a/npm_modules/cli/debugger/debugger-bootstrap.js
+++ b/npm_modules/cli/debugger/debugger-bootstrap.js
@@ -1,6 +1,6 @@
// DOM event wiring and initial debugger boot sequence.
elements.screen.addEventListener('click', event => {
- selectPreviewNodeAtEvent(event);
+ void dispatchTapInput(event);
});
elements.screen.addEventListener('mousedown', event => {
@@ -29,6 +29,23 @@ document.addEventListener('click', event => {
const issue = event.target.closest('[data-issue-node]');
if (issue && issue.dataset.issueNode) void requestDebuggerAction('selectNode', { id: issue.dataset.issueNode });
+ const providerTab = event.target.closest('[data-provider-tab]');
+ if (providerTab) setActiveProviderTab(providerTab.dataset.providerTab);
+
+ const resetSetting = event.target.closest('[data-reset-setting]');
+ if (resetSetting && !resetSetting.disabled) {
+ void changeDebugSetting('reset', resetSetting.dataset.resetSetting);
+ }
+
+ if (event.target.closest('#sqlPreviousButton')) {
+ state.providers.sqlOffset = Math.max(0, state.providers.sqlOffset - state.providers.sqlLimit);
+ void loadSqlTable();
+ }
+ if (event.target.closest('#sqlNextButton')) {
+ state.providers.sqlOffset += state.providers.sqlLimit;
+ void loadSqlTable();
+ }
+
const target = event.target.closest('.target');
if (target) void requestDebuggerAction('selectTarget', { id: target.dataset.targetId });
});
@@ -41,11 +58,12 @@ document.addEventListener('keydown', event => {
actionable.click();
});
-function selectTarget(id) {
+async function selectTarget(id) {
const target = debuggerTargets().find(candidate => candidate.id === id);
if (!target) return;
+ if (!(await preparePerformanceTraceTargetSwitch(target))) return;
if (target.daemonPort && target.port) {
- setTargetPort(target.port);
+ await setTargetPort(target.port, target);
state.snapshot.target = {
...state.snapshot.target,
...target,
@@ -61,9 +79,17 @@ function selectTarget(id) {
}
state.followLatestTarget = false;
if (target.clientId && target.contextId) {
+ resetDebuggerToolsForTarget();
+ state.snapshot.target = {
+ ...state.snapshot.target,
+ ...target,
+ state: 'attached',
+ };
+ render();
loadRealSnapshot(target);
return;
}
+ resetDebuggerToolsForTarget();
state.snapshot.target = {
...state.snapshot.target,
id: target.id,
@@ -83,7 +109,24 @@ elements.treeSearch.addEventListener('input', renderTree);
elements.logSearch.addEventListener('input', renderLogs);
elements.htmlPreviewRoot.addEventListener('click', event => {
- selectHtmlPreviewNodeAtEvent(event);
+ void dispatchHtmlPreviewTapInput(event);
+});
+elements.htmlPreviewRoot.addEventListener(
+ 'wheel',
+ event => {
+ void dispatchHtmlPreviewScrollInput(event);
+ },
+ { passive: false },
+);
+elements.htmlPreviewRoot.addEventListener('input', dispatchHtmlPreviewTextInput);
+elements.htmlPreviewRoot.addEventListener('keydown', event => {
+ void dispatchHtmlPreviewKeyInput(event);
+});
+elements.htmlPreviewRoot.addEventListener('focusin', event => {
+ void dispatchHtmlPreviewFocusInput(event, true);
+});
+elements.htmlPreviewRoot.addEventListener('focusout', event => {
+ void dispatchHtmlPreviewFocusInput(event, false);
});
document
@@ -149,11 +192,64 @@ elements.autoRefreshToggle.addEventListener(
'change',
() => void requestDebuggerAction('setAutoRefresh', { enabled: elements.autoRefreshToggle.checked }),
);
+elements.traceStartButton.addEventListener('click', () => void requestDebuggerAction('startRendererTrace'));
+elements.traceStopButton.addEventListener('click', () => void requestDebuggerAction('stopRendererTrace'));
+elements.traceCaptureButton.addEventListener('click', () => void requestDebuggerAction('captureRendererTrace'));
+elements.traceExportButton.addEventListener('click', exportPerformanceTrace);
elements.profileRefreshButton.addEventListener('click', () => void requestDebuggerAction('refreshHermesContexts'));
elements.profileStartButton.addEventListener('click', () => void requestDebuggerAction('startCpuProfile'));
elements.profileStopButton.addEventListener('click', () => void requestDebuggerAction('stopCpuProfile'));
elements.profileCaptureButton.addEventListener('click', () => void requestDebuggerAction('captureCpuProfile'));
elements.profileExportButton.addEventListener('click', exportCpuProfile);
+elements.providerRefreshButton.addEventListener('click', () => {
+ void requestDebuggerAction('refreshDebuggerProviders', getSelectedTargetParams());
+});
+elements.settingsRefreshButton.addEventListener('click', () => {
+ void requestDebuggerAction('refreshDebugSettings', getSelectedTargetParams());
+});
+elements.settingsGroupSelect.addEventListener('change', () => {
+ state.settings.selectedGroupId = elements.settingsGroupSelect.value || null;
+ renderDebugSettings();
+});
+
+document.addEventListener('change', event => {
+ if (event.target.id === 'storageProviderSelect' || event.target.id === 'sqlProviderSelect') {
+ const kind = event.target.id === 'sqlProviderSelect' ? 'sql' : 'storage';
+ if (!selectDebuggerProvider(kind, event.target.value)) return;
+ renderDebuggerProviders();
+ if (kind === 'sql') void loadSqlDatabases();
+ else void loadStorageProvider();
+ return;
+ }
+ if (event.target.id === 'sqlDatabaseSelect') {
+ state.providers.selectedDatabaseId = event.target.value;
+ const database = selectedSQLDatabase();
+ state.providers.selectedTable = Array.isArray(database?.tables) ? database.tables[0]?.name || null : null;
+ state.providers.sqlOffset = 0;
+ state.providers.sqlTable = null;
+ void loadSqlTable();
+ return;
+ }
+ if (event.target.id === 'sqlTableSelect') {
+ state.providers.selectedTable = event.target.value;
+ state.providers.sqlOffset = 0;
+ state.providers.sqlTable = null;
+ void loadSqlTable();
+ return;
+ }
+ const setting = event.target.closest('[data-setting-id]');
+ if (setting) {
+ const parsed = readDebugSettingInput(setting);
+ if (parsed.error) {
+ setting.setCustomValidity?.(parsed.error);
+ setting.reportValidity?.();
+ addLog('warn', 'settings', parsed.error);
+ return;
+ }
+ setting.setCustomValidity?.('');
+ void changeDebugSetting('set', setting.dataset.settingId, parsed.value);
+ }
+});
document.getElementById('copyPathButton').addEventListener('click', async () => {
const path = getPathToNode(state.selectedNodeId)
@@ -193,4 +289,7 @@ applyDebuggerSessionDomState();
setAutoRefresh(state.autoRefresh, { silent: true });
installDebuggerSessionPersistence();
void refreshProfileContexts({ silent: true });
-refreshTargets({ silent: restoredDebuggerSession, autoAttach: !state.manualDetach });
+void (async () => {
+ await recoverPerformanceTraceState();
+ await refreshTargets({ silent: restoredDebuggerSession, autoAttach: !state.manualDetach });
+})();
diff --git a/npm_modules/cli/debugger/debugger-model.js b/npm_modules/cli/debugger/debugger-model.js
index 51ca53ad7..f0624b883 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}`));
-
- 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 };
+ 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 (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,48 +273,67 @@ 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) {
- // Include the node's own translation in its absolute bounds so hit-testing
- // (findNodeAtPoint) and overlay boxes track transformed elements. childOffset then derives
- // from absolute, so children still inherit the translation without double-counting it.
- const translation = getTranslation(node);
- absolute = {
- x: offset.x + frame.x + translation.x,
- y: offset.y + frame.y + translation.y,
- width: frame.width,
- height: frame.height,
- };
- const scrollOffset = getScrollOffset(node);
- childOffset = {
- x: absolute.x - scrollOffset.x,
- y: absolute.y - scrollOffset.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) {
+ // Include the node's own translation in its absolute bounds so hit-testing
+ // and overlay boxes track transformed elements. Children inherit that
+ // translation through the absolute origin without double-counting it.
+ const translation = getTranslation(node);
+ absolute = {
+ x: offset.x + frame.x + translation.x,
+ y: offset.y + frame.y + translation.y,
+ width: frame.width,
+ height: frame.height,
+ };
+ const scrollOffset = getScrollOffset(node);
+ childOffset = {
+ x: absolute.x - scrollOffset.x,
+ y: absolute.y - scrollOffset.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 };
@@ -374,14 +406,16 @@ function findNodeAtPoint(point, predicate = () => true) {
return hits[0]?.node || null;
}
-function findPreviewNodeAtEvent(event) {
+function findInputNodeAtEvent(event) {
const point = pointFromScreenEvent(event);
const overlayNode = event.target.closest('.overlay-node');
const overlayTreeNode =
overlayNode && elements.screen.contains(overlayNode) ? findNode(overlayNode.dataset.nodeId) : null;
- return overlayTreeNode && getElementIdForNode(overlayTreeNode) !== null
- ? overlayTreeNode
- : findNodeAtPoint(point, node => getElementIdForNode(node) !== null);
+ const nodeWithElement =
+ overlayTreeNode && getElementIdForNode(overlayTreeNode) !== null
+ ? overlayTreeNode
+ : findNodeAtPoint(point, node => getElementIdForNode(node) !== null);
+ return { node: nodeWithElement, point };
}
function findOverlayNodeAtEvent(event) {
@@ -390,6 +424,109 @@ function findOverlayNodeAtEvent(event) {
return findNode(overlayNode.dataset.nodeId);
}
+let debuggerInputDispatchTail = Promise.resolve(null);
+
+function captureDebuggerInputTarget() {
+ if (state.source !== 'daemon') return null;
+ const params = getSelectedTargetParams();
+ if (!params.clientId || !params.contextId || !Number.isFinite(params.port)) return null;
+ return Object.freeze({
+ port: params.port,
+ clientId: params.clientId,
+ contextId: params.contextId,
+ });
+}
+
+function debuggerInputTargetKey(target) {
+ return JSON.stringify([target.port, target.clientId, target.contextId]);
+}
+
+function debuggerInputTargetElementKey(target, elementId) {
+ return JSON.stringify([target.port, target.clientId, target.contextId, elementId]);
+}
+
+function isSelectedDebuggerInputTarget(target) {
+ const selectedTarget = captureDebuggerInputTarget();
+ return selectedTarget !== null && debuggerInputTargetKey(selectedTarget) === debuggerInputTargetKey(target);
+}
+
+function scheduleInputRefresh(target, delayMs) {
+ const key = debuggerInputTargetKey(target);
+ const previousTimer = state.inputRefreshTimers.get(key);
+ if (previousTimer) window.clearTimeout(previousTimer);
+ const timer = window.setTimeout(() => {
+ state.inputRefreshTimers.delete(key);
+ if (!isSelectedDebuggerInputTarget(target)) return;
+ loadRealSnapshot(target, { silent: true, preserveSelection: true });
+ }, delayMs);
+ state.inputRefreshTimers.set(key, timer);
+}
+
+async function dispatchDebuggerInput(target, payload, options) {
+ if (!target) {
+ addLog('warn', 'input', 'Attach to a live Valdi daemon target before dispatching input.');
+ return null;
+ }
+
+ try {
+ const result = await apiPost('/api/input', target, payload, { timeoutMs: 5000 });
+ const input = result.input || {};
+ if (input.handled) {
+ if (!options.quiet) {
+ const action = input.action ? ` via ${input.action}` : '';
+ addLog('info', 'input', `${payload.type} handled by #${input.elementId}${action}.`);
+ }
+ if (options.refresh !== false) {
+ scheduleInputRefresh(target, options.refreshDelayMs ?? 120);
+ }
+ } else if (!options.quiet) {
+ addLog('warn', 'input', input.message || `${payload.type} was not handled.`);
+ }
+ return input;
+ } catch (error) {
+ addLog('error', 'input', `${payload.type} failed: ${error.message}`);
+ return null;
+ }
+}
+
+function reserveDebuggerInput(target) {
+ let releaseReservation;
+ let cancelled = false;
+ const reservation = new Promise(resolve => {
+ releaseReservation = resolve;
+ });
+ const dispatch = debuggerInputDispatchTail
+ .catch(error => {
+ addLog('warn', 'input', `Continuing after a queued input failed: ${error?.message || String(error)}`);
+ return null;
+ })
+ .then(() => reservation)
+ .then(input => (input && !cancelled ? dispatchDebuggerInput(target, input.payload, input.options) : null));
+ debuggerInputDispatchTail = dispatch;
+ let released = false;
+ return Object.freeze({
+ dispatch(payload, options) {
+ if (!released) {
+ released = true;
+ releaseReservation({ payload, options });
+ }
+ return dispatch;
+ },
+ cancel() {
+ cancelled = true;
+ if (!released) {
+ released = true;
+ releaseReservation(null);
+ }
+ return dispatch;
+ },
+ });
+}
+
+function enqueueDebuggerInput(target, payload, options) {
+ return reserveDebuggerInput(target).dispatch(payload, options);
+}
+
function getPageScrollTarget() {
return document.scrollingElement || document.documentElement || document.body;
}
@@ -442,16 +579,37 @@ function forwardPreviewWheelToPage(event) {
});
}
-function selectPreviewNodeAtEvent(event) {
+async function dispatchTapInput(event) {
if (event.target.closest('.html-preview-root')) return;
const overlaySelection = findOverlayNodeAtEvent(event);
- const selectedPreviewNode = overlaySelection || findPreviewNodeAtEvent(event);
+ const { node, point } = findInputNodeAtEvent(event);
+ const selectedPreviewNode = overlaySelection || node;
+ const elementId = getElementIdForNode(node);
if (selectedPreviewNode) {
event.preventDefault();
event.stopPropagation();
selectPreviewNode(selectedPreviewNode);
}
+
+ if (elementId === null) {
+ if (!selectedPreviewNode) {
+ addLog('warn', 'input', 'Click ignored because no Valdi element was under the cursor.');
+ }
+ return;
+ }
+
+ const target = captureDebuggerInputTarget();
+ await enqueueDebuggerInput(
+ target,
+ {
+ type: 'tap',
+ elementId,
+ x: point.x,
+ y: point.y,
+ },
+ {},
+ );
}
function isInteractiveNode(node) {
@@ -524,26 +682,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) {
@@ -551,24 +714,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) {
@@ -590,7 +737,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 {
@@ -600,15 +747,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-performance.js b/npm_modules/cli/debugger/debugger-performance.js
index 426be5121..3c8ac7b90 100644
--- a/npm_modules/cli/debugger/debugger-performance.js
+++ b/npm_modules/cli/debugger/debugger-performance.js
@@ -1,8 +1,8 @@
-// Hermes CPU profile UI behavior.
-function readSecondsInput(input) {
+// Renderer trace and Hermes CPU profile UI behavior.
+function readSecondsInput(input, maximumSeconds) {
const value = Number.parseFloat(input.value);
if (!Number.isFinite(value)) return 5;
- return Math.max(0.1, Math.min(60, value));
+ return Math.max(0.1, Math.min(maximumSeconds, value));
}
function formatMs(value) {
@@ -10,6 +10,112 @@ function formatMs(value) {
return `${value.toFixed(value >= 100 ? 0 : 1)} ms`;
}
+function traceSummaryText(result) {
+ const summary = result?.summary;
+ if (!summary || !result.traceCount) return 'No renderer trace captured.';
+ const topComponent = summary.topComponents?.[0];
+ const topTrigger = summary.topViewModelTriggers?.[0];
+ const parts = [
+ `${result.traceCount} events`,
+ `${summary.durationTraceCount || 0} durations`,
+ 'process-wide capture',
+ ];
+ if (result.captureTarget?.contextId) {
+ parts.push(`requested from ${escapeHtml(String(result.captureTarget.contextId))}`);
+ }
+ if (result.droppedTraceEventCount) parts.push(`${result.droppedTraceEventCount} dropped`);
+ if (result.traceEventLimitReached) parts.push('native trace limit reached');
+ if (result.elapsedMs !== undefined) parts.push(`window ${formatMs(result.elapsedMs)}`);
+ if (topComponent) parts.push(`top render ${escapeHtml(topComponent.name)} ${formatMs(topComponent.durationMs)}`);
+ if (topTrigger) parts.push(`top trigger ${escapeHtml(topTrigger.name)} x${topTrigger.count}`);
+ return parts.join(' · ');
+}
+
+function traceEventsHtml(result) {
+ const traces = Array.isArray(result?.traces) ? result.traces : [];
+ if (!traces.length) return '';
+ const baseMicros = traces.reduce((minimum, trace) => Math.min(minimum, trace.startMicros), traces[0].startMicros);
+ const visibleTraces = traces.slice(0, 80);
+ const eventLimitText =
+ visibleTraces.length === traces.length ? '' : ` (first ${visibleTraces.length} of ${traces.length})`;
+ const rows = visibleTraces.map(trace => {
+ const durationMs = Math.max(0, trace.endMicros - trace.startMicros) / 1000;
+ const details = JSON.stringify(
+ {
+ name: trace.trace,
+ threadId: trace.threadId,
+ startMs: (trace.startMicros - baseMicros) / 1000,
+ durationMs,
+ },
+ null,
+ 2,
+ );
+ return `
+
+
+ ${escapeHtml(trace.trace)}
+ ${escapeHtml(((trace.startMicros - baseMicros) / 1000).toFixed(2))} ms
+ ${escapeHtml(formatMs(durationMs))}
+
+ ${escapeHtml(details)}
+
+ `;
+ });
+ return `
+
+ ${rows.join('')}
+ `;
+}
+
+function buildPerformanceTracePerfettoPayload(result) {
+ const traces = Array.isArray(result?.traces) ? result.traces : [];
+ const minimumStartMicros = traces.reduce(
+ (minimum, trace) => Math.min(minimum, trace.startMicros),
+ traces[0]?.startMicros || 0,
+ );
+ const threadIds = Array.from(new Set(traces.map(trace => trace.threadId)))
+ .sort((left, right) => left - right)
+ .slice(0, 256);
+ const traceEvents = [
+ {
+ name: 'process_name',
+ ph: 'M',
+ pid: 1,
+ args: { name: 'Valdi' },
+ },
+ ...threadIds.map(threadId => ({
+ name: 'thread_name',
+ ph: 'M',
+ pid: 1,
+ tid: threadId,
+ args: { name: `Valdi thread ${threadId}` },
+ })),
+ ];
+ for (const trace of traces) {
+ const instant = /(?:^|\.)Renderer\.viewModelChange\.[^.]+\..+$/.test(trace.trace);
+ const event = {
+ name: trace.trace,
+ cat: 'valdi',
+ ph: instant ? 'i' : 'X',
+ pid: 1,
+ tid: trace.threadId,
+ ts: trace.startMicros - minimumStartMicros,
+ };
+ if (instant) event.s = 't';
+ else event.dur = Math.max(0, trace.endMicros - trace.startMicros);
+ traceEvents.push(event);
+ }
+ return {
+ displayTimeUnit: 'ms',
+ metadata: result?.perfettoMetadata || {},
+ traceEvents,
+ };
+}
+
function profileSummaryText(result) {
const summary = result?.summary;
if (!summary || !result.profile) return 'No CPU profile captured.';
@@ -23,6 +129,57 @@ function profileSummaryText(result) {
return parts.join(' · ');
}
+function normalizePerformanceTraceTarget(target) {
+ const port = Number(target?.port);
+ if (!Number.isFinite(port) || target?.clientId === undefined || target?.contextId === undefined) return null;
+ return {
+ port,
+ clientId: String(target.clientId),
+ contextId: String(target.contextId),
+ };
+}
+
+function performanceTraceTargetsMatch(left, right) {
+ const normalizedLeft = normalizePerformanceTraceTarget(left);
+ const normalizedRight = normalizePerformanceTraceTarget(right);
+ return Boolean(
+ normalizedLeft &&
+ normalizedRight &&
+ normalizedLeft.port === normalizedRight.port &&
+ normalizedLeft.clientId === normalizedRight.clientId &&
+ normalizedLeft.contextId === normalizedRight.contextId,
+ );
+}
+
+function syncActiveTraceState(result, fallbackTarget) {
+ state.performance.traceStateUnknown = false;
+ state.performance.traceActive = Boolean(result?.recording);
+ state.performance.traceResultPending = Boolean(result?.completedRecordingAvailable);
+ const resultContextId = result?.recording ? result.contextId : result?.completedContextId;
+ const resultTarget =
+ normalizePerformanceTraceTarget(result?.captureTarget) || normalizePerformanceTraceTarget(fallbackTarget);
+ if ((result?.recording || result?.completedRecordingAvailable) && resultTarget) {
+ state.performance.activeTraceTarget = {
+ ...resultTarget,
+ contextId: resultContextId === undefined ? resultTarget.contextId : String(resultContextId),
+ };
+ } else if (!state.performance.traceCapturePending) {
+ state.performance.activeTraceTarget = null;
+ }
+ persistPerformanceTraceState();
+}
+
+function persistPerformanceTraceState() {
+ if (typeof persistDebuggerSessionState === 'function') persistDebuggerSessionState();
+}
+
+function markPerformanceTraceStateUnknown(target) {
+ const normalizedTarget = normalizePerformanceTraceTarget(target);
+ state.performance.traceStateUnknown = true;
+ if (normalizedTarget) state.performance.activeTraceTarget = normalizedTarget;
+ persistPerformanceTraceState();
+}
+
function syncActiveProfileState(activeProfile) {
const contextId = activeProfile?.contextId;
state.performance.profileActive = Boolean(activeProfile?.profiling);
@@ -32,6 +189,32 @@ function syncActiveProfileState(activeProfile) {
function renderPerformance() {
const perf = state.performance;
+ const traceTargetAvailable = hasSelectedLiveTarget();
+ const traceSupported = perf.traceSupported !== false;
+ const traceBusy = perf.traceActive || perf.traceCapturePending || perf.traceResultPending || perf.traceStateUnknown;
+ elements.traceStatusPill.textContent = perf.traceCapturePending
+ ? 'Capturing'
+ : perf.traceStateUnknown
+ ? 'Unknown'
+ : perf.traceActive
+ ? 'Recording'
+ : perf.traceResultPending
+ ? 'Ready'
+ : 'Idle';
+ elements.traceStatusPill.className = `source-pill ${traceBusy ? 'live' : ''}`;
+ elements.traceStartButton.disabled = !traceTargetAvailable || !traceSupported || traceBusy;
+ elements.traceStopButton.disabled =
+ !traceTargetAvailable ||
+ (!perf.traceActive && !perf.traceResultPending && !perf.traceStateUnknown);
+ elements.traceCaptureButton.disabled = !traceTargetAvailable || !traceSupported || perf.traceCapturePending;
+ elements.traceCaptureButton.textContent =
+ perf.traceActive || perf.traceResultPending || perf.traceStateUnknown ? 'Stop & Capture' : 'Capture';
+ elements.rendererTracingToggle.disabled = traceBusy;
+ elements.traceDurationInput.disabled = traceBusy;
+ elements.traceExportButton.disabled = !Array.isArray(perf.lastTrace?.traces);
+ elements.traceSummary.innerHTML = traceSummaryText(perf.lastTrace);
+ elements.traceEvents.innerHTML = traceEventsHtml(perf.lastTrace);
+
elements.profileStatusPill.textContent = perf.profileActive ? 'Recording' : 'Idle';
elements.profileStatusPill.className = `source-pill ${perf.profileActive ? 'live' : ''}`;
elements.profileStartButton.disabled = perf.profileActive;
@@ -51,6 +234,257 @@ function renderPerformance() {
].join('');
}
+async function refreshPerformanceTraceStatus(options) {
+ const targetParams =
+ normalizePerformanceTraceTarget(options.target) ||
+ normalizePerformanceTraceTarget(state.performance.activeTraceTarget) ||
+ normalizePerformanceTraceTarget(getSelectedTargetParams());
+ if (!targetParams) {
+ syncActiveTraceState(null);
+ state.performance.traceSupported = true;
+ if (options.render !== false) renderPerformance();
+ return null;
+ }
+
+ try {
+ const result = await apiGet('/api/performance/trace/status', targetParams, {
+ timeoutMs: options.timeoutMs || 10000,
+ });
+ syncActiveTraceState(result, targetParams);
+ state.performance.traceSupported = result.tracingSupported === true;
+ return result;
+ } catch (error) {
+ if (!options.silent) addLog('warn', 'trace', error.message);
+ return null;
+ } finally {
+ if (options.render !== false) renderPerformance();
+ }
+}
+
+async function startPerformanceTrace() {
+ const targetParams = normalizePerformanceTraceTarget(getSelectedTargetParams());
+ if (!targetParams) return;
+ const wasUnknown = state.performance.traceStateUnknown === true;
+ try {
+ const status = await refreshPerformanceTraceStatus({ silent: true, render: false, target: targetParams });
+ if (!status) {
+ addLog('warn', 'trace', 'Could not verify renderer trace status; start was not sent.');
+ return;
+ }
+ if (wasUnknown) {
+ addLog('info', 'trace', 'Recovering the uncertain renderer trace before starting another.');
+ await stopPerformanceTrace({ target: targetParams, render: false });
+ return;
+ }
+ if (status?.recording || status?.completedRecordingAvailable) {
+ syncActiveTraceState(status, targetParams);
+ addLog('warn', 'trace', 'A renderer trace is active or waiting to be retrieved. Use Stop first.');
+ return;
+ }
+ if (status.tracingSupported !== true) {
+ addLog('warn', 'trace', 'This Valdi runtime does not support renderer trace capture.');
+ return;
+ }
+ markPerformanceTraceStateUnknown(targetParams);
+ const result = await apiPost('/api/performance/trace/start', targetParams, {
+ rendererTracing: elements.rendererTracingToggle.checked,
+ });
+ syncActiveTraceState(result, targetParams);
+ state.performance.traceSupported = result.tracingSupported !== false;
+ addLog('info', 'trace', `Started process-wide renderer trace from context ${targetParams.contextId}.`);
+ } catch (error) {
+ addLog('error', 'trace', error.message);
+ const status = await refreshPerformanceTraceStatus({ silent: true, render: false, target: targetParams });
+ if (!status) markPerformanceTraceStateUnknown(targetParams);
+ } finally {
+ renderPerformance();
+ }
+}
+
+async function stopPerformanceTrace(options) {
+ const targetParams =
+ normalizePerformanceTraceTarget(options.target) ||
+ normalizePerformanceTraceTarget(state.performance.activeTraceTarget) ||
+ normalizePerformanceTraceTarget(getSelectedTargetParams());
+ if (!targetParams) return false;
+ markPerformanceTraceStateUnknown(targetParams);
+ const applyResult = result => {
+ syncActiveTraceState(result, targetParams);
+ state.performance.traceSupported = result.tracingSupported === true;
+ state.performance.lastTrace = result;
+ addLog('info', 'trace', `Captured ${result.traceCount || 0} Valdi trace event(s).`);
+ if (result.completionError) addLog('warn', 'trace', result.completionError);
+ return true;
+ };
+ try {
+ const result = await apiPost('/api/performance/trace/stop', targetParams, {}, { timeoutMs: 30000 });
+ return applyResult(result);
+ } catch (error) {
+ addLog('error', 'trace', error.message);
+ const status = await refreshPerformanceTraceStatus({ silent: true, render: false, target: targetParams });
+ if (!status) {
+ markPerformanceTraceStateUnknown(targetParams);
+ return false;
+ }
+ try {
+ const replayedResult = await apiPost('/api/performance/trace/stop', targetParams, {}, { timeoutMs: 30000 });
+ return applyResult(replayedResult);
+ } catch (replayError) {
+ addLog('error', 'trace', `Could not retrieve the retained renderer trace: ${replayError.message}`);
+ markPerformanceTraceStateUnknown(targetParams);
+ return false;
+ }
+ } finally {
+ if (options.render !== false) renderPerformance();
+ }
+}
+
+async function preparePerformanceTraceTargetSwitch(nextTarget) {
+ const activeTarget = normalizePerformanceTraceTarget(state.performance.activeTraceTarget);
+ if (!activeTarget) {
+ if (state.performance.traceActive || state.performance.traceResultPending || state.performance.traceStateUnknown) {
+ addLog('warn', 'trace', 'Recover the uncertain renderer trace before switching targets.');
+ return false;
+ }
+ return true;
+ }
+ if (performanceTraceTargetsMatch(activeTarget, nextTarget)) return true;
+ if (state.performance.traceCapturePending) {
+ addLog('warn', 'trace', 'Wait for the process-wide renderer trace capture to finish before switching targets.');
+ return false;
+ }
+ if (!state.performance.traceActive && !state.performance.traceResultPending && !state.performance.traceStateUnknown) {
+ return true;
+ }
+ addLog('info', 'trace', 'Stopping the active process-wide renderer trace before switching targets.');
+ if (await stopPerformanceTrace({ target: activeTarget, render: false })) return true;
+ if (!(await isPerformanceTraceTargetVerifiedMissing(activeTarget))) return false;
+
+ state.performance.traceActive = false;
+ state.performance.traceResultPending = false;
+ state.performance.traceStateUnknown = false;
+ state.performance.activeTraceTarget = null;
+ state.performance.traceSupported = true;
+ persistPerformanceTraceState();
+ addLog('warn', 'trace', 'The previous trace target disconnected; discarded its unrecoverable trace state.');
+ return true;
+}
+
+async function isPerformanceTraceTargetVerifiedMissing(target) {
+ let status;
+ try {
+ status = await apiGet('/api/status', { port: target.port }, { timeoutMs: 5000 });
+ } catch {
+ // A transport failure does not prove that the prior context disappeared.
+ return false;
+ }
+
+ const portStatus = status?.ports?.find(candidate => Number(candidate.port) === target.port);
+ if (!portStatus) return false;
+ // inspectPort reports connected:false for transient connection and inventory failures too.
+ // Only a successfully connected inventory can prove that the prior target disappeared.
+ if (portStatus.connected !== true || !Array.isArray(portStatus.clients)) return false;
+
+ const client = portStatus.clients.find(candidate => String(candidate.client_id) === target.clientId);
+ if (!client) return true;
+ if (client.contextError !== null && client.contextError !== undefined) return false;
+ if (!Array.isArray(client.contexts)) return false;
+ return !client.contexts.some(context => String(context.id) === target.contextId);
+}
+
+async function recoverPerformanceTraceState() {
+ const target =
+ normalizePerformanceTraceTarget(state.performance.activeTraceTarget) ||
+ normalizePerformanceTraceTarget(getSelectedTargetParams());
+ if (!target) return;
+ const wasUnknown = state.performance.traceStateUnknown === true;
+ const status = await refreshPerformanceTraceStatus({ silent: true, render: false, target });
+ if (!status) {
+ if (wasUnknown) markPerformanceTraceStateUnknown(target);
+ renderPerformance();
+ return;
+ }
+ if (status.recording || status.completedRecordingAvailable || wasUnknown) {
+ const completedTarget = {
+ ...target,
+ contextId: String(status.completedContextId || target.contextId),
+ };
+ const recovered = await stopPerformanceTrace({ target: completedTarget, render: false });
+ if (recovered) {
+ addLog('info', 'trace', 'Recovered the completed process-wide renderer trace from the runtime.');
+ }
+ }
+ renderPerformance();
+}
+
+async function capturePerformanceTrace() {
+ const durationMs = Math.round(readSecondsInput(elements.traceDurationInput, 15) * 1000);
+ const targetParams = normalizePerformanceTraceTarget(getSelectedTargetParams());
+ if (!targetParams) return;
+ const wasUnknown = state.performance.traceStateUnknown === true;
+ try {
+ const status = await refreshPerformanceTraceStatus({ silent: true, render: false, target: targetParams });
+ if (!status) {
+ addLog('warn', 'trace', 'Could not verify renderer trace status; capture was not sent.');
+ return;
+ }
+ if (status.recording || status.completedRecordingAvailable || state.performance.traceActive || wasUnknown) {
+ addLog('info', 'trace', 'Stopping active renderer trace.');
+ await stopPerformanceTrace({ target: state.performance.activeTraceTarget || targetParams });
+ return;
+ }
+ if (status.tracingSupported !== true) {
+ addLog('warn', 'trace', 'This Valdi runtime does not support renderer trace capture.');
+ return;
+ }
+ state.performance.traceCapturePending = true;
+ markPerformanceTraceStateUnknown(targetParams);
+ renderPerformance();
+ addLog('info', 'trace', `Capturing renderer trace for ${(durationMs / 1000).toFixed(1)}s.`);
+ const result = await apiPost(
+ '/api/performance/trace/capture',
+ targetParams,
+ {
+ rendererTracing: elements.rendererTracingToggle.checked,
+ durationMs,
+ },
+ { timeoutMs: durationMs + 15000 },
+ );
+ syncActiveTraceState(result, targetParams);
+ state.performance.traceSupported = result.tracingSupported === true;
+ state.performance.lastTrace = result;
+ addLog('info', 'trace', `Captured ${result.traceCount || 0} Valdi trace event(s).`);
+ } catch (error) {
+ addLog('error', 'trace', error.message);
+ const status = await refreshPerformanceTraceStatus({ silent: true, render: false, target: targetParams });
+ if (status) {
+ await stopPerformanceTrace({ target: targetParams, render: false });
+ } else {
+ markPerformanceTraceStateUnknown(targetParams);
+ }
+ } finally {
+ state.performance.traceCapturePending = false;
+ if (
+ !state.performance.traceActive &&
+ !state.performance.traceResultPending &&
+ !state.performance.traceStateUnknown
+ ) {
+ state.performance.activeTraceTarget = null;
+ }
+ renderPerformance();
+ }
+}
+
+function exportPerformanceTrace() {
+ const result = state.performance.lastTrace;
+ if (!Array.isArray(result?.traces)) return;
+ downloadJson(
+ buildPerformanceTracePerfettoPayload(result),
+ `${targetFilePrefix()}-valdi-trace.json`,
+ 'Exported Valdi trace JSON for Perfetto.',
+ );
+}
+
async function refreshProfileContexts(options) {
try {
const result = await apiGet('/api/performance/profile/contexts', {}, { timeoutMs: 5000 });
@@ -106,7 +540,7 @@ async function stopCpuProfile() {
}
async function captureCpuProfile() {
- const durationMs = Math.round(readSecondsInput(elements.profileDurationInput) * 1000);
+ const durationMs = Math.round(readSecondsInput(elements.profileDurationInput, 60) * 1000);
const contextId = selectedProfileContextId();
syncActiveProfileState({ profiling: true, contextId });
renderPerformance();
diff --git a/npm_modules/cli/debugger/debugger-preview-html.js b/npm_modules/cli/debugger/debugger-preview-html.js
index 0bea06dc9..d16f3c5ee 100644
--- a/npm_modules/cli/debugger/debugger-preview-html.js
+++ b/npm_modules/cli/debugger/debugger-preview-html.js
@@ -1,22 +1,88 @@
// Live HTML projection for the debugger preview frame.
+const HTML_PREVIEW_TEXT_INPUT_DEBOUNCE_MS = 180;
+const HTML_PREVIEW_SCROLL_INPUT_DEBOUNCE_MS = 60;
+const htmlPreviewTextInputs = new Map();
+const htmlPreviewScrollInputs = new Map();
+const htmlPreviewInputDispatches = new Map();
+const htmlPreviewQueuedInputs = new Set();
+const htmlPreviewElementTargets = new WeakMap();
+const htmlPreviewElementIncarnations = new WeakMap();
+const htmlPreviewElementEditEpochs = new WeakMap();
+const htmlPreviewElementFocusEpochs = new WeakMap();
+let htmlPreviewIncarnation = 0;
+
+function getHtmlPreviewElementEpoch(epochs, element) {
+ return epochs.get(element) || 0;
+}
+
+function advanceHtmlPreviewElementEpoch(epochs, element) {
+ const epoch = getHtmlPreviewElementEpoch(epochs, element) + 1;
+ epochs.set(element, epoch);
+ return epoch;
+}
+
+function isCurrentHtmlPreviewElement(element, incarnation) {
+ return incarnation === htmlPreviewIncarnation && htmlPreviewElementIncarnations.get(element) === incarnation;
+}
+
+function associateHtmlPreviewElement(element, target) {
+ if (target) htmlPreviewElementTargets.set(element, target);
+ htmlPreviewElementIncarnations.set(element, htmlPreviewIncarnation);
+}
+
+function cancelPendingHtmlPreviewInputs(pendingInputs) {
+ for (const pendingInput of pendingInputs.values()) {
+ window.clearTimeout(pendingInput.timer);
+ void pendingInput.reservation.cancel();
+ }
+ pendingInputs.clear();
+}
+
+function trackQueuedHtmlPreviewInput(incarnation, reservation, dispatch) {
+ const queuedInput = { incarnation, reservation };
+ htmlPreviewQueuedInputs.add(queuedInput);
+ const clearQueuedInput = () => htmlPreviewQueuedInputs.delete(queuedInput);
+ void dispatch.then(clearQueuedInput, clearQueuedInput);
+}
+
+function cancelQueuedHtmlPreviewInputs() {
+ for (const queuedInput of htmlPreviewQueuedInputs) {
+ void queuedInput.reservation.cancel();
+ }
+ htmlPreviewQueuedInputs.clear();
+}
+
+function beginHtmlPreviewIncarnation() {
+ htmlPreviewIncarnation += 1;
+ cancelPendingHtmlPreviewInputs(htmlPreviewTextInputs);
+ cancelPendingHtmlPreviewInputs(htmlPreviewScrollInputs);
+ cancelQueuedHtmlPreviewInputs();
+ htmlPreviewInputDispatches.clear();
+ return htmlPreviewIncarnation;
+}
+
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;
@@ -24,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;
@@ -56,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 || '');
}
@@ -64,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 : '';
}
@@ -147,24 +213,24 @@ function applyPreviewTextStyles(element, attrs) {
}
}
-function createPreviewElement(node) {
- const tag = String(node.tag || 'view').toLowerCase();
+function createPreviewElement(node, effectivelyDisabled) {
+ const tag = valdiDebuggerTreeModel.formatValue(node.tag || 'view', 0).toLowerCase();
const attrs = getNodeAttributes(node);
if (tag === 'textfield') {
const input = document.createElement('input');
input.type = previewBoolean(attrs.secureTextEntry) ? 'password' : 'text';
input.value = previewValue(attrs.value);
input.placeholder = previewValue(attrs.placeholder);
- input.disabled = previewBoolean(attrs.enabled, true) === false;
- input.readOnly = true;
+ input.disabled = effectivelyDisabled;
+ input.readOnly = previewBoolean(attrs.editable, true) === false;
return input;
}
if (tag === 'textview' && previewBoolean(attrs.editable, true)) {
const textArea = document.createElement('textarea');
textArea.value = previewValue(attrs.value);
textArea.placeholder = previewValue(attrs.placeholder);
- textArea.disabled = previewBoolean(attrs.enabled, true) === false;
- textArea.readOnly = true;
+ textArea.disabled = effectivelyDisabled;
+ textArea.readOnly = previewBoolean(attrs.editable, true) === false;
return textArea;
}
if (tag === 'image' || tag === 'animatedimage') {
@@ -196,14 +262,15 @@ function createPreviewElement(node) {
if (tag === 'button') {
const button = document.createElement('button');
button.textContent = previewText(node, attrs);
+ button.disabled = effectivelyDisabled;
return button;
}
return document.createElement('div');
}
-function finishPreviewElement(element, node) {
+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)}`);
@@ -211,7 +278,12 @@ function finishPreviewElement(element, node) {
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');
+ element.setAttribute('aria-disabled', 'true');
+ }
element.title = describeOverlayNode(node);
applyPreviewFrame(element, node);
applyPreviewBoxStyles(element, node);
@@ -234,17 +306,41 @@ function finishPreviewElement(element, node) {
}
}
-function appendPreviewNode(node, parent) {
+function isHtmlPreviewEffectivelyDisabled(node, ancestorDisabled) {
+ const attrs = getNodeAttributes(node);
+ return (
+ ancestorDisabled ||
+ previewBoolean(attrs.enabled, true) === false ||
+ previewBoolean(attrs.accessibilityStateDisabled, false) ||
+ previewBoolean(attrs.touchEnabled, true) === false
+ );
+}
+
+function appendPreviewNode(node, parent, target, ancestorDisabled) {
if (!node) return;
- if (node.element) {
- const element = createPreviewElement(node);
- finishPreviewElement(element, node);
- parent.appendChild(element);
- const childParent = canHostPreviewChildren(element) ? element : parent;
- for (const child of node.children || []) appendPreviewNode(child, childParent);
- return;
- }
- for (const child of node.children || []) appendPreviewNode(child, parent);
+ 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) {
@@ -273,6 +369,7 @@ function markHtmlPreviewSelection() {
}
function renderHtmlPreview() {
+ beginHtmlPreviewIncarnation();
elements.htmlPreviewRoot.replaceChildren();
elements.htmlPreviewRoot.classList.toggle('active', false);
if (!hasSnapshotTree()) return false;
@@ -285,7 +382,7 @@ function renderHtmlPreview() {
canvas.className = 'html-preview-canvas';
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
- appendPreviewNode(state.snapshot.tree, canvas);
+ appendPreviewNode(state.snapshot.tree, canvas, captureDebuggerInputTarget(), false);
if (!canvas.querySelector('[data-preview-node-id]')) return false;
elements.htmlPreviewRoot.appendChild(canvas);
@@ -297,15 +394,283 @@ function renderHtmlPreview() {
function previewElementNodeFromEvent(event) {
const element = event.target.closest('[data-preview-node-id]');
- if (!element || !elements.htmlPreviewRoot.contains(element)) return { element: null, node: null };
+ if (!element || !elements.htmlPreviewRoot.contains(element)) {
+ return { element: null, node: null, elementId: null, target: null };
+ }
const node = findNode(element.dataset.previewNodeId);
- return { element, node };
+ const parsedElementId = Number.parseInt(element.dataset.previewElementId || '', 10);
+ const incarnation = htmlPreviewElementIncarnations.get(element);
+ if (incarnation !== htmlPreviewIncarnation) {
+ return { element: null, node: null, elementId: null, target: null, incarnation: null };
+ }
+ return {
+ element,
+ node,
+ elementId: Number.isNaN(parsedElementId) ? null : parsedElementId,
+ target: htmlPreviewElementTargets.get(element) || null,
+ incarnation,
+ };
+}
+
+function isEditableHtmlPreviewTarget(target) {
+ return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable;
+}
+
+function enqueueHtmlPreviewInput(target, elementId, payload, options) {
+ if (!target) return Promise.resolve(null);
+ const key = debuggerInputTargetElementKey(target, elementId);
+ const dispatch = enqueueDebuggerInput(target, payload, options);
+ htmlPreviewInputDispatches.set(key, dispatch);
+ const clearDispatch = () => {
+ if (htmlPreviewInputDispatches.get(key) === dispatch) {
+ htmlPreviewInputDispatches.delete(key);
+ }
+ };
+ void dispatch.then(clearDispatch, clearDispatch);
+ return dispatch;
+}
+
+function flushHtmlPreviewTextInput(target, elementId) {
+ if (!target) return Promise.resolve(null);
+ const key = debuggerInputTargetElementKey(target, elementId);
+ const pendingInput = htmlPreviewTextInputs.get(key);
+ if (!pendingInput) return Promise.resolve(null);
+ window.clearTimeout(pendingInput.timer);
+ htmlPreviewTextInputs.delete(key);
+ if (
+ pendingInput.incarnation !== htmlPreviewIncarnation ||
+ !isCurrentHtmlPreviewElement(pendingInput.element, pendingInput.incarnation)
+ ) {
+ return pendingInput.reservation.cancel();
+ }
+ const dispatch = pendingInput.reservation.dispatch(pendingInput.input, {
+ quiet: true,
+ refresh: false,
+ refreshDelayMs: 180,
+ });
+ trackQueuedHtmlPreviewInput(pendingInput.incarnation, pendingInput.reservation, dispatch);
+ void dispatch.then(input => {
+ reconcileHtmlPreviewTextInputResult(
+ pendingInput.element,
+ pendingInput.input.text,
+ input,
+ pendingInput.incarnation,
+ pendingInput.editEpoch,
+ );
+ });
+ htmlPreviewInputDispatches.set(key, dispatch);
+ const clearDispatch = () => {
+ if (htmlPreviewInputDispatches.get(key) === dispatch) {
+ htmlPreviewInputDispatches.delete(key);
+ }
+ };
+ void dispatch.then(clearDispatch, clearDispatch);
+ return dispatch;
+}
+
+function dispatchHtmlPreviewFocusInput(event, focused) {
+ const { element, node, elementId, target } = previewElementNodeFromEvent(event);
+ if (!element || !node || elementId === null || !target || !isEditableHtmlPreviewTarget(event.target)) {
+ return Promise.resolve(null);
+ }
+ selectPreviewNode(node);
+ advanceHtmlPreviewElementEpoch(htmlPreviewElementFocusEpochs, element);
+ if (!focused) {
+ void flushHtmlPreviewTextInput(target, elementId);
+ }
+ return enqueueHtmlPreviewInput(
+ target,
+ elementId,
+ {
+ type: 'focus',
+ elementId,
+ focused,
+ },
+ {
+ quiet: true,
+ refresh: !focused,
+ refreshDelayMs: 120,
+ },
+ );
}
-function selectHtmlPreviewNodeAtEvent(event) {
- const { element, node } = previewElementNodeFromEvent(event);
+async function dispatchHtmlPreviewTapInput(event) {
+ const { element, node, elementId, target } = previewElementNodeFromEvent(event);
if (!element || !node) return;
+ event.stopPropagation();
+ selectPreviewNode(node);
+ if (elementId === null || !target) return;
+
+ if (isEditableHtmlPreviewTarget(event.target)) return;
+ event.preventDefault();
+ const point = pointFromScreenEvent(event);
+ await enqueueDebuggerInput(
+ target,
+ {
+ type: 'tap',
+ elementId,
+ x: point.x,
+ y: point.y,
+ },
+ {
+ refreshDelayMs: 120,
+ },
+ );
+}
+
+function dispatchHtmlPreviewScrollInput(event) {
+ if (!hasSnapshotTree() || state.source !== 'daemon') return;
+ const previewElement = event.target.closest('[data-preview-node-id]');
+ if (!previewElement || !elements.htmlPreviewRoot.contains(previewElement)) return;
+ const target = htmlPreviewElementTargets.get(previewElement) || null;
+ const incarnation = htmlPreviewElementIncarnations.get(previewElement);
+ if (!target || incarnation !== htmlPreviewIncarnation) return;
+ const point = pointFromScreenEvent(event);
+ const scrollNode =
+ findNodeAtPoint(
+ point,
+ node => String(node.tag || '').toLowerCase() === 'scroll' && getElementIdForNode(node) !== null,
+ ) || findNodeAtPoint(point, node => hasScrollState(node) && getElementIdForNode(node) !== null);
+ const node = scrollNode || findNodeAtPoint(point, candidate => getElementIdForNode(candidate) !== null);
+ const elementId = getElementIdForNode(node);
+ if (elementId === null) return;
event.preventDefault();
event.stopPropagation();
+ const key = debuggerInputTargetElementKey(target, elementId);
+ const pending = htmlPreviewScrollInputs.get(key);
+ if (pending) {
+ pending.input.deltaX += event.deltaX;
+ pending.input.deltaY += event.deltaY;
+ pending.input.x = point.x;
+ pending.input.y = point.y;
+ return;
+ }
+
+ const input = {
+ type: 'scroll',
+ elementId,
+ x: point.x,
+ y: point.y,
+ deltaX: event.deltaX,
+ deltaY: event.deltaY,
+ };
+ const reservation = reserveDebuggerInput(target);
+ const timer = window.setTimeout(() => {
+ if (incarnation !== htmlPreviewIncarnation || htmlPreviewScrollInputs.get(key)?.reservation !== reservation) {
+ void reservation.cancel();
+ return;
+ }
+ htmlPreviewScrollInputs.delete(key);
+ const dispatch = reservation.dispatch(input, {
+ quiet: true,
+ refreshDelayMs: 120,
+ });
+ trackQueuedHtmlPreviewInput(incarnation, reservation, dispatch);
+ }, HTML_PREVIEW_SCROLL_INPUT_DEBOUNCE_MS);
+ htmlPreviewScrollInputs.set(key, { incarnation, input, target, reservation, timer });
+}
+
+function dispatchHtmlPreviewTextInput(event) {
+ const { element, node, elementId, target, incarnation } = previewElementNodeFromEvent(event);
+ if (!element || !node || elementId === null || !target) return;
+ if (!(event.target instanceof HTMLInputElement) && !(event.target instanceof HTMLTextAreaElement)) return;
selectPreviewNode(node);
+ const editEpoch = advanceHtmlPreviewElementEpoch(htmlPreviewElementEditEpochs, element);
+ const text = event.target.value;
+ const key = debuggerInputTargetElementKey(target, elementId);
+ const pendingInput = htmlPreviewTextInputs.get(key);
+ if (pendingInput) {
+ window.clearTimeout(pendingInput.timer);
+ }
+ const input = {
+ type: 'text',
+ elementId,
+ text,
+ selectionStart: event.target.selectionStart,
+ selectionEnd: event.target.selectionEnd,
+ };
+ const reservation = pendingInput?.reservation || reserveDebuggerInput(target);
+ const timer = window.setTimeout(() => {
+ void flushHtmlPreviewTextInput(target, elementId);
+ }, HTML_PREVIEW_TEXT_INPUT_DEBOUNCE_MS);
+ htmlPreviewTextInputs.set(key, { editEpoch, element, incarnation, input, target, timer, reservation });
+}
+
+function reconcileHtmlPreviewTextInputResult(element, expectedValue, input, incarnation, editEpoch) {
+ if (!input?.handled || typeof input.value !== 'string') return;
+ if (!isCurrentHtmlPreviewElement(element, incarnation)) return;
+ if (getHtmlPreviewElementEpoch(htmlPreviewElementEditEpochs, element) !== editEpoch) return;
+ if (element.value !== expectedValue) return;
+ if (element.value !== input.value) element.value = input.value;
+ if (!Number.isInteger(input.selectionStart) || !Number.isInteger(input.selectionEnd)) return;
+ const selectionStart = Math.max(0, Math.min(input.value.length, input.selectionStart));
+ const selectionEnd = Math.max(selectionStart, Math.min(input.value.length, input.selectionEnd));
+ if (element.selectionStart !== selectionStart || element.selectionEnd !== selectionEnd) {
+ element.setSelectionRange(selectionStart, selectionEnd);
+ }
+}
+
+function dispatchHtmlPreviewKeyInput(event) {
+ const { element, node, elementId, target, incarnation } = previewElementNodeFromEvent(event);
+ if (!element || !node || elementId === null || !target) return Promise.resolve(null);
+ if (!(event.target instanceof HTMLInputElement) && !(event.target instanceof HTMLTextAreaElement)) {
+ return Promise.resolve(null);
+ }
+ if (event.key !== 'Enter' && event.key !== 'Escape') return Promise.resolve(null);
+ selectPreviewNode(node);
+ const attributes = getNodeAttributes(node);
+ const editEpoch = advanceHtmlPreviewElementEpoch(htmlPreviewElementEditEpochs, element);
+ const focusEpoch = getHtmlPreviewElementEpoch(htmlPreviewElementFocusEpochs, element);
+ const selectionStart = event.target.selectionStart;
+ const selectionEnd = event.target.selectionEnd;
+ if (event.key === 'Enter' && event.target instanceof HTMLTextAreaElement) {
+ // The runtime key path owns the newline and onWillChange/onChange/onReturn callbacks. Prevent the
+ // browser from also emitting a full-text input, then mirror the pending newline locally so typing
+ // stays responsive while the authoritative runtime result is in flight.
+ event.preventDefault();
+ if (!previewBoolean(attributes.ignoreNewlines, false)) {
+ const start = Math.max(0, Math.min(event.target.value.length, selectionStart));
+ const end = Math.max(start, Math.min(event.target.value.length, selectionEnd));
+ const caret = start + 1;
+ event.target.value = event.target.value.slice(0, start) + '\n' + event.target.value.slice(end);
+ event.target.setSelectionRange(caret, caret);
+ }
+ }
+ const expectedValue = event.target.value;
+ void flushHtmlPreviewTextInput(target, elementId);
+ const dispatch = enqueueHtmlPreviewInput(
+ target,
+ elementId,
+ {
+ type: 'key',
+ elementId,
+ key: event.key,
+ selectionStart,
+ selectionEnd,
+ },
+ {
+ quiet: true,
+ refresh: false,
+ refreshDelayMs: 180,
+ },
+ );
+ const configuredClose = attributes.closesWhenReturnKeyPressed;
+ const closesOnReturn =
+ configuredClose === undefined
+ ? event.target instanceof HTMLInputElement
+ : previewBoolean(configuredClose, event.target instanceof HTMLInputElement);
+ const shouldBlurAfterDispatch = event.key === 'Escape' || closesOnReturn;
+ return dispatch.then(input => {
+ reconcileHtmlPreviewTextInputResult(event.target, expectedValue, input, incarnation, editEpoch);
+ if (
+ shouldBlurAfterDispatch &&
+ input?.handled &&
+ isCurrentHtmlPreviewElement(event.target, incarnation) &&
+ getHtmlPreviewElementEpoch(htmlPreviewElementFocusEpochs, event.target) === focusEpoch &&
+ document.activeElement === event.target
+ ) {
+ event.target.blur();
+ }
+ return input;
+ });
}
diff --git a/npm_modules/cli/debugger/debugger-providers.js b/npm_modules/cli/debugger/debugger-providers.js
new file mode 100644
index 000000000..e6329e162
--- /dev/null
+++ b/npm_modules/cli/debugger/debugger-providers.js
@@ -0,0 +1,389 @@
+// Generic debugger-provider discovery plus Storage and SQL inspector surfaces.
+function debuggerTargetKey(target = getSelectedTargetParams()) {
+ const port = Number(target?.port ?? target?.proxyPort);
+ const clientId = target?.clientId;
+ const contextId = target?.contextId;
+ if (!Number.isInteger(port) || port < 1 || port > 65535 || !clientId || !contextId) return null;
+ return JSON.stringify([port, String(clientId), String(contextId)]);
+}
+
+function debuggerTargetRequest() {
+ const selected = getSelectedTargetParams();
+ return {
+ params: {
+ port: Number(selected.port),
+ clientId: String(selected.clientId || ''),
+ contextId: String(selected.contextId || ''),
+ },
+ targetKey: debuggerTargetKey(selected),
+ };
+}
+
+function beginDebuggerToolRequest(toolState) {
+ const target = debuggerTargetRequest();
+ toolState.generation += 1;
+ return { ...target, generation: toolState.generation };
+}
+
+function debuggerToolRequestIsCurrent(toolState, request, result) {
+ if (!request || request.generation !== toolState.generation) return false;
+ if (!request.targetKey || request.targetKey !== debuggerTargetKey()) return false;
+ const returnedTargetKey = debuggerTargetKey(result?.target);
+ if (returnedTargetKey !== null && returnedTargetKey !== request.targetKey) return false;
+ if (result?.handled === true && returnedTargetKey === null) return false;
+ return true;
+}
+
+function beginDebuggerActionResult(toolState, params, result) {
+ const requestedTargetKey = debuggerTargetKey(params);
+ if (!requestedTargetKey || requestedTargetKey !== debuggerTargetKey()) return null;
+ const returnedTargetKey = debuggerTargetKey(result?.target);
+ if (returnedTargetKey !== null && returnedTargetKey !== requestedTargetKey) return null;
+ if (result?.handled === true && returnedTargetKey === null) return null;
+ toolState.generation += 1;
+ return {
+ generation: toolState.generation,
+ params: {
+ port: Number(params.port),
+ clientId: String(params.clientId),
+ contextId: String(params.contextId),
+ },
+ targetKey: requestedTargetKey,
+ };
+}
+
+function providerRegistryData(result) {
+ return result?.handled === true && result.status === 'handled' && result.data && typeof result.data === 'object'
+ ? result.data
+ : null;
+}
+
+function registeredDebuggerProviders() {
+ return Array.isArray(state.providers.registry?.providers) ? state.providers.registry.providers : [];
+}
+
+function debuggerProvidersForKind(kind) {
+ return registeredDebuggerProviders().filter(provider => provider.kind === kind);
+}
+
+function selectedDebuggerProviderId(kind) {
+ if (kind === 'storage') return state.providers.selectedStorageProviderId;
+ if (kind === 'sql') return state.providers.selectedSqlProviderId;
+ return null;
+}
+
+function setSelectedDebuggerProviderId(kind, providerId) {
+ if (kind === 'storage') state.providers.selectedStorageProviderId = providerId;
+ if (kind === 'sql') state.providers.selectedSqlProviderId = providerId;
+}
+
+function debuggerProviderForKind(kind) {
+ const providers = debuggerProvidersForKind(kind);
+ const selectedId = selectedDebuggerProviderId(kind);
+ const provider = providers.find(candidate => candidate.id === selectedId) || providers[0] || null;
+ setSelectedDebuggerProviderId(kind, provider?.id || null);
+ return provider;
+}
+
+function selectDebuggerProvider(kind, providerId) {
+ const provider = debuggerProvidersForKind(kind).find(candidate => candidate.id === providerId);
+ if (!provider) return false;
+ setSelectedDebuggerProviderId(kind, provider.id);
+ state.providers.error = null;
+ if (kind === 'storage') {
+ state.providers.storage = null;
+ } else if (kind === 'sql') {
+ state.providers.sql = null;
+ state.providers.sqlTable = null;
+ state.providers.selectedDatabaseId = null;
+ state.providers.selectedTable = null;
+ state.providers.sqlOffset = 0;
+ }
+ return true;
+}
+
+function renderDebuggerProviderSelector(kind) {
+ const providers = debuggerProvidersForKind(kind);
+ if (providers.length <= 1) return '';
+ const selected = debuggerProviderForKind(kind);
+ const selectId = kind === 'sql' ? 'sqlProviderSelect' : 'storageProviderSelect';
+ return `Provider${providers.map(provider => `${escapeHtml(provider.label || provider.id)} `).join('')}
`;
+}
+
+function debuggerProviderUnavailableMessage(kind, label) {
+ if (!hasSelectedLiveTarget()) return `Attach to a live Valdi target to inspect ${label}.`;
+ if (state.providers.error) return state.providers.error;
+ const provider = debuggerProviderForKind(kind);
+ if (provider && provider.available === false) return provider.message || `${label} is unavailable in this target.`;
+ return `${label} support is not registered by this target runtime.`;
+}
+
+async function requestDebuggerProvider(request, providerId, action, params = {}) {
+ return apiPost(
+ '/api/debugger/providers/request',
+ request.params,
+ { providerId, action, params },
+ { timeoutMs: 12000 },
+ );
+}
+
+function debuggerProviderResponseData(result) {
+ const envelope = providerRegistryData(result);
+ if (!envelope) return null;
+ if (envelope.stale === true) return null;
+ if (envelope.unavailable === true || envelope.busy === true) {
+ state.providers.error = envelope.message || 'The debugger provider is unavailable.';
+ return null;
+ }
+ state.providers.error = null;
+ return envelope.data && typeof envelope.data === 'object' ? envelope.data : null;
+}
+
+async function loadStorageProvider(options = {}, request = beginDebuggerToolRequest(state.providers)) {
+ const provider = debuggerProviderForKind('storage');
+ if (!provider || provider.available === false) {
+ if (debuggerToolRequestIsCurrent(state.providers, request)) {
+ state.providers.storage = null;
+ renderDebuggerProviders();
+ }
+ return;
+ }
+ try {
+ const result = await requestDebuggerProvider(request, provider.id, 'snapshot');
+ if (!debuggerToolRequestIsCurrent(state.providers, request, result)) return;
+ state.providers.storage = debuggerProviderResponseData(result);
+ if (!result.handled) state.providers.error = result.message || 'Storage provider request was not handled.';
+ } catch (error) {
+ if (!debuggerToolRequestIsCurrent(state.providers, request)) return;
+ state.providers.storage = null;
+ state.providers.error = error.message;
+ if (!options.silent) addLog('error', 'storage', error.message);
+ }
+ if (debuggerToolRequestIsCurrent(state.providers, request)) renderDebuggerProviders();
+}
+
+async function loadSqlDatabases(options = {}, request = beginDebuggerToolRequest(state.providers)) {
+ const provider = debuggerProviderForKind('sql');
+ if (!provider || provider.available === false) {
+ if (debuggerToolRequestIsCurrent(state.providers, request)) {
+ state.providers.sql = null;
+ state.providers.sqlTable = null;
+ renderDebuggerProviders();
+ }
+ return;
+ }
+ try {
+ const result = await requestDebuggerProvider(request, provider.id, 'list');
+ if (!debuggerToolRequestIsCurrent(state.providers, request, result)) return;
+ state.providers.sql = debuggerProviderResponseData(result);
+ const databases = Array.isArray(state.providers.sql?.databases) ? state.providers.sql.databases : [];
+ const selectedDatabase = databases.find(database => database.id === state.providers.selectedDatabaseId) || databases[0];
+ state.providers.selectedDatabaseId = selectedDatabase?.id || null;
+ const tables = Array.isArray(selectedDatabase?.tables) ? selectedDatabase.tables : [];
+ const selectedTable = tables.find(table => table.name === state.providers.selectedTable) || tables[0];
+ state.providers.selectedTable = selectedTable?.name || null;
+ state.providers.sqlOffset = 0;
+ state.providers.sqlTable = null;
+ if (state.providers.selectedDatabaseId && state.providers.selectedTable) {
+ await loadSqlTable({ silent: true }, request);
+ }
+ } catch (error) {
+ if (!debuggerToolRequestIsCurrent(state.providers, request)) return;
+ state.providers.sql = null;
+ state.providers.sqlTable = null;
+ state.providers.error = error.message;
+ if (!options.silent) addLog('error', 'sql', error.message);
+ }
+ if (debuggerToolRequestIsCurrent(state.providers, request)) renderDebuggerProviders();
+}
+
+async function loadSqlTable(options = {}, request = beginDebuggerToolRequest(state.providers)) {
+ const provider = debuggerProviderForKind('sql');
+ if (!provider || !state.providers.selectedDatabaseId || !state.providers.selectedTable) return;
+ const databaseId = state.providers.selectedDatabaseId;
+ const table = state.providers.selectedTable;
+ const offset = state.providers.sqlOffset;
+ state.providers.sqlLoading = true;
+ renderDebuggerProviders();
+ try {
+ const result = await requestDebuggerProvider(request, provider.id, 'table', {
+ databaseId,
+ table,
+ limit: state.providers.sqlLimit,
+ offset,
+ });
+ if (!debuggerToolRequestIsCurrent(state.providers, request, result)) return;
+ if (
+ databaseId !== state.providers.selectedDatabaseId ||
+ table !== state.providers.selectedTable ||
+ offset !== state.providers.sqlOffset
+ ) return;
+ state.providers.sqlTable = debuggerProviderResponseData(result);
+ if (!result.handled) state.providers.error = result.message || 'SQL table request was not handled.';
+ } catch (error) {
+ if (!debuggerToolRequestIsCurrent(state.providers, request)) return;
+ state.providers.sqlTable = null;
+ state.providers.error = error.message;
+ if (!options.silent) addLog('error', 'sql', error.message);
+ } finally {
+ if (debuggerToolRequestIsCurrent(state.providers, request)) {
+ state.providers.sqlLoading = false;
+ renderDebuggerProviders();
+ }
+ }
+}
+
+async function hydrateDebuggerProviders(options = {}, request = beginDebuggerToolRequest(state.providers)) {
+ if (state.providers.activeTab === 'storage') await loadStorageProvider(options, request);
+ else await loadSqlDatabases(options, request);
+}
+
+async function refreshDebuggerProviders(options = {}) {
+ const request = beginDebuggerToolRequest(state.providers);
+ state.providers.targetKey = request.targetKey;
+ state.providers.loading = true;
+ state.providers.error = null;
+ state.providers.registry = null;
+ state.providers.storage = null;
+ state.providers.sql = null;
+ state.providers.sqlTable = null;
+ renderDebuggerProviders();
+ if (!request.targetKey || !hasSelectedLiveTarget()) {
+ if (debuggerToolRequestIsCurrent(state.providers, request)) {
+ state.providers.loading = false;
+ state.providers.error = 'Attach to a live Valdi target before checking debugger providers.';
+ renderDebuggerProviders();
+ }
+ return;
+ }
+ try {
+ const result = await apiGet('/api/debugger/providers', request.params, { timeoutMs: 7000 });
+ if (!debuggerToolRequestIsCurrent(state.providers, request, result)) return;
+ state.providers.registry = providerRegistryData(result);
+ if (!result.handled) state.providers.error = result.message || 'The target does not expose debugger providers.';
+ if (!options.silent) addLog(result.handled ? 'info' : 'warn', 'data', result.message || 'Refreshed debugger providers.');
+ } catch (error) {
+ if (!debuggerToolRequestIsCurrent(state.providers, request)) return;
+ state.providers.error = error.message;
+ if (!options.silent) addLog('error', 'data', error.message);
+ } finally {
+ if (debuggerToolRequestIsCurrent(state.providers, request)) state.providers.loading = false;
+ }
+ if (!debuggerToolRequestIsCurrent(state.providers, request)) return;
+ await hydrateDebuggerProviders(options, request);
+ if (debuggerToolRequestIsCurrent(state.providers, request)) renderDebuggerProviders();
+}
+
+function applyDebuggerProvidersResult(result, params) {
+ const request = beginDebuggerActionResult(state.providers, params, result);
+ if (!request) return false;
+ state.providers.registry = providerRegistryData(result);
+ state.providers.error = result?.handled ? null : result?.message || 'The target does not expose debugger providers.';
+ state.providers.targetKey = request.targetKey;
+ void hydrateDebuggerProviders({ silent: true }, request);
+ renderDebuggerProviders();
+ return true;
+}
+
+function renderProviderStatus(provider, unavailableMessage) {
+ const available = provider?.available === true;
+ return `${escapeHtml(available ? provider.message || 'Available' : unavailableMessage)}
`;
+}
+
+function renderStorageEntry(entry) {
+ const truncated = entry.valueTruncated || entry.keyTruncated;
+ const metadata = [entry.encoding ?? 'unknown'];
+ if (entry.byteLength !== undefined) metadata.push(`${entry.byteLength} bytes`);
+ if (truncated) metadata.push('truncated');
+ return `${escapeHtml(entry.key || '')}${escapeHtml(metadata.join(' · '))} ${escapeHtml(entry.value ?? '')} `;
+}
+
+function renderStorageProjectionNote(storage) {
+ const projection = storage?.projection;
+ if (projection?.truncated !== true) return '';
+ const details = [];
+ const storesOmitted = Number(projection.storesOmitted);
+ const entriesOmitted = Number(projection.entriesOmitted);
+ const truncatedFields = Number(projection.truncatedFields);
+ const invalidFields = Number(projection.invalidFields);
+ if (Number.isInteger(storesOmitted) && storesOmitted > 0) details.push(`${storesOmitted} ${storesOmitted === 1 ? 'store' : 'stores'} omitted`);
+ if (Number.isInteger(entriesOmitted) && entriesOmitted > 0) details.push(`${entriesOmitted} ${entriesOmitted === 1 ? 'entry' : 'entries'} omitted`);
+ if (projection.sourceEntryCountIncomplete === true) details.push('source entry total incomplete');
+ if (Number.isInteger(truncatedFields) && truncatedFields > 0) details.push(`${truncatedFields} ${truncatedFields === 1 ? 'field' : 'fields'} truncated`);
+ if (Number.isInteger(invalidFields) && invalidFields > 0) details.push(`${invalidFields} invalid ${invalidFields === 1 ? 'field' : 'fields'} omitted`);
+ const detail = details.length ? `: ${details.join(', ')}` : '';
+ return `The Storage transport projection was truncated${escapeHtml(detail)}.
`;
+}
+
+function renderStoragePanel() {
+ const provider = debuggerProviderForKind('storage');
+ const selector = renderDebuggerProviderSelector('storage');
+ const unavailable = debuggerProviderUnavailableMessage('storage', 'Storage');
+ if (!provider || provider.available !== true || !state.providers.storage) {
+ return `${selector}${renderProviderStatus(provider, unavailable)}${escapeHtml(unavailable)}
`;
+ }
+ const stores = Array.isArray(state.providers.storage.stores) ? state.providers.storage.stores : [];
+ const storageIssues = `${state.providers.storage.storageError ? `${escapeHtml(state.providers.storage.storageError)}
` : ''}${state.providers.storage.storageInspectionTruncated ? 'Persistent browser-storage discovery was truncated by the inspection budget.
' : ''}${renderStorageProjectionNote(state.providers.storage)}`;
+ if (!stores.length) return `${selector}${renderProviderStatus(provider, unavailable)}${storageIssues}The registered Storage provider returned no stores.
`;
+ return `${selector}${renderProviderStatus(provider, unavailable)}${storageIssues}${stores.map(store => {
+ const entries = Array.isArray(store.entries) ? store.entries : [];
+ return `
${escapeHtml(store.name || 'Storage')} ${escapeHtml(store.backend ?? 'unknown')} · ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'} ${store.error ? `${escapeHtml(store.error)}
` : ''}${entries.length ? entries.map(renderStorageEntry).join('') : 'This store is empty.
'}${store.entriesTruncated ? 'Additional entries were omitted by the debugger snapshot budget.
' : ''}${store.inspectionTruncated ? 'Entry inspection stopped at the debugger scan limit.
' : ''}`;
+ }).join('')}
`;
+}
+
+function selectedSQLDatabase() {
+ const databases = Array.isArray(state.providers.sql?.databases) ? state.providers.sql.databases : [];
+ return databases.find(database => database.id === state.providers.selectedDatabaseId) || null;
+}
+
+function renderSQLTable() {
+ const data = state.providers.sqlTable;
+ if (state.providers.sqlLoading) return 'Loading SQL rows…
';
+ if (!data) return 'Select a database table to inspect its rows.
';
+ const columns = Array.isArray(data.columns) ? data.columns : [];
+ const rows = Array.isArray(data.rows) ? data.rows : [];
+ const names = columns.map(column => column.name).filter(Boolean);
+ const headings = names.map(name => `${escapeHtml(name)} `).join('');
+ const body = rows.map(row => `${names.map(name => `${escapeHtml(row?.[name] ?? 'NULL')} `).join('')} `).join('');
+ const rowCount = Number(data.rowCount);
+ const hasNext = rows.length >= state.providers.sqlLimit && (!Number.isFinite(rowCount) || state.providers.sqlOffset + rows.length < rowCount);
+ return `${rows.length ? '' : 'This table returned no rows.
'}`;
+}
+
+function renderSqlPanel() {
+ const provider = debuggerProviderForKind('sql');
+ const selector = renderDebuggerProviderSelector('sql');
+ const unavailable = debuggerProviderUnavailableMessage('sql', 'SQL');
+ if (!provider || provider.available !== true || !state.providers.sql) {
+ return `${selector}${renderProviderStatus(provider, unavailable)}${escapeHtml(unavailable)}
`;
+ }
+ const databases = Array.isArray(state.providers.sql.databases) ? state.providers.sql.databases : [];
+ if (!databases.length) return `${selector}${renderProviderStatus(provider, unavailable)}The registered SQL provider returned no databases.
`;
+ const database = selectedSQLDatabase() || databases[0];
+ const tables = Array.isArray(database?.tables) ? database.tables : [];
+ return `${selector}${renderProviderStatus(provider, unavailable)}Database${databases.map(item => `${escapeHtml(item.name || item.id)} `).join('')} Table${tables.map(table => `${escapeHtml(table.name)} `).join('')}
${tables.length ? renderSQLTable() : 'This database does not expose inspectable tables.
'}`;
+}
+
+function renderDebuggerProviders() {
+ if (!elements.providerContent || !elements.providerStatusPill) return;
+ elements.providerRefreshButton.disabled = state.providers.loading;
+ elements.providerStatusPill.textContent = state.providers.loading ? 'Loading' : state.providers.error ? 'Unavailable' : state.providers.registry ? 'Ready' : 'Idle';
+ elements.providerStatusPill.className = `source-pill ${state.providers.registry && !state.providers.error ? 'live' : ''}`;
+ document.querySelectorAll('[data-provider-tab]').forEach(button => button.classList.toggle('active', button.dataset.providerTab === state.providers.activeTab));
+ if (state.providers.loading && !state.providers.registry) {
+ elements.providerContent.innerHTML = 'Discovering target debugger providers…
';
+ return;
+ }
+ const metadataNote = state.providers.registry?.metadataTruncated === true
+ ? `Optional provider metadata was omitted to keep discovery within the debugger response budget${Number.isInteger(state.providers.registry.omittedMetadataFields) ? ` (${state.providers.registry.omittedMetadataFields} fields)` : ''}.
`
+ : '';
+ const panel = state.providers.activeTab === 'storage' ? renderStoragePanel() : renderSqlPanel();
+ elements.providerContent.innerHTML = metadataNote + panel;
+}
+
+function setActiveProviderTab(tab) {
+ state.providers.activeTab = tab === 'sql' ? 'sql' : 'storage';
+ renderDebuggerProviders();
+ if (!state.providers.registry || state.providers.targetKey !== debuggerTargetKey()) void refreshDebuggerProviders({ silent: true });
+ else void hydrateDebuggerProviders({ silent: true });
+}
diff --git a/npm_modules/cli/debugger/debugger-render.js b/npm_modules/cli/debugger/debugger-render.js
index f92b4c6ce..c22222368 100644
--- a/npm_modules/cli/debugger/debugger-render.js
+++ b/npm_modules/cli/debugger/debugger-render.js
@@ -9,6 +9,8 @@ function render() {
renderInspector();
renderLogs();
renderPerformance();
+ renderDebuggerProviders();
+ renderDebugSettings();
}
function renderPreview() {
@@ -157,34 +159,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 +220,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 +339,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 +357,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;
@@ -418,7 +440,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}
` : ''}
@@ -467,12 +489,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 12930ed32..0e82b963d 100644
--- a/npm_modules/cli/debugger/debugger-runtime.js
+++ b/npm_modules/cli/debugger/debugger-runtime.js
@@ -82,7 +82,7 @@ function startRuntimeLogStream(options = {}) {
if (state.runtimeLogStream && state.runtimeLogStreamKey === key) return;
stopRuntimeLogStream();
- const source = new EventSource(`/api/runtime-logs/stream?${key}`);
+ const source = new EventSource(debuggerEventSourceUrl(`/api/runtime-logs/stream?${key}`));
state.runtimeLogStream = source;
state.runtimeLogStreamKey = key;
@@ -147,6 +147,9 @@ async function refreshTargets(options = {}) {
const previousTargetId = state.snapshot.target?.id || null;
const selectedTarget = chooseLiveTarget(targets);
const targetChanged = selectedTarget.id !== previousTargetId;
+ if (targetChanged && !(await preparePerformanceTraceTargetSwitch(selectedTarget))) {
+ return;
+ }
state.snapshot.targets = markSelectedTarget(targets, selectedTarget);
state.snapshot.target = {
...state.snapshot.target,
@@ -217,12 +220,14 @@ async function loadRealSnapshot(target, options = {}) {
? { port: target.port || target.proxyPort, clientId: target.clientId, contextId: target.contextId }
: getSelectedTargetParams();
const previousSelectedNodeId = state.selectedNodeId;
+ const previousTargetKey = debuggerTargetKey();
const previousLogs = state.source === 'daemon' ? state.snapshot.logs : [];
try {
if (!options.silent) addLog('info', 'daemon', `Fetching Valdi tree from port ${params.port}.`);
const snapshot = await apiGet('/api/snapshot', params, { timeoutMs: 20000 });
state.snapshot = decorateSnapshot(snapshot);
+ if (debuggerTargetKey() !== previousTargetKey) resetDebuggerToolsForTarget();
state.snapshot.logs = [...previousLogs, ...(state.snapshot.logs || [])];
trimRuntimeLogs();
state.selectedNodeId =
@@ -238,6 +243,7 @@ async function loadRealSnapshot(target, options = {}) {
state.lastUpdated = new Date().toLocaleTimeString();
elements.portSelect.value = String(state.snapshot.target.proxyPort || params.port);
render();
+ maybeRefreshDebuggerTools(state.activeSection);
if (!options.silent || !state.rootSnapshotImage) {
void captureRootSnapshot(params, options);
}
@@ -270,12 +276,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 +375,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-session.js b/npm_modules/cli/debugger/debugger-session.js
index ca548d503..afa02e22d 100644
--- a/npm_modules/cli/debugger/debugger-session.js
+++ b/npm_modules/cli/debugger/debugger-session.js
@@ -34,8 +34,16 @@ function debuggerSessionPayload() {
treeSearch: elements.treeSearch.value,
logSearch: elements.logSearch.value,
expandedNodeIds: Array.from(state.expandedNodeIds),
+ rendererTracing: elements.rendererTracingToggle.checked,
+ traceDuration: elements.traceDurationInput.value,
+ activeTraceTarget: state.performance.activeTraceTarget,
+ traceActive: state.performance.traceActive,
+ traceResultPending: state.performance.traceResultPending,
+ traceStateUnknown: state.performance.traceStateUnknown,
profileDuration: elements.profileDurationInput.value,
profileContextId: elements.profileContextSelect.value,
+ providerTab: state.providers.activeTab,
+ settingsGroupId: state.settings.selectedGroupId,
};
}
@@ -107,8 +115,48 @@ function restoreDebuggerSessionState() {
if (saved.treeSearch !== undefined) elements.treeSearch.value = String(saved.treeSearch);
if (saved.logSearch !== undefined) elements.logSearch.value = String(saved.logSearch);
+ if (typeof saved.rendererTracing === 'boolean') elements.rendererTracingToggle.checked = saved.rendererTracing;
+ if (saved.traceDuration !== undefined) elements.traceDurationInput.value = String(saved.traceDuration);
+ const persistedTraceMayBeActive =
+ saved.traceStateUnknown === true || saved.traceActive === true || saved.traceResultPending === true;
+ if (saved.activeTraceTarget && typeof saved.activeTraceTarget === 'object') {
+ const activeTracePort = Number.parseInt(String(saved.activeTraceTarget.port || ''), 10);
+ if (
+ Number.isFinite(activeTracePort) &&
+ saved.activeTraceTarget.clientId !== undefined &&
+ saved.activeTraceTarget.contextId !== undefined
+ ) {
+ state.performance.activeTraceTarget = {
+ port: activeTracePort,
+ clientId: String(saved.activeTraceTarget.clientId),
+ contextId: String(saved.activeTraceTarget.contextId),
+ };
+ }
+ }
+ if (state.performance.activeTraceTarget === null && persistedTraceMayBeActive) {
+ const fallbackTraceTarget = state.snapshot.target || saved.target;
+ const fallbackTracePort = Number.parseInt(String(fallbackTraceTarget?.port || ''), 10);
+ if (
+ Number.isFinite(fallbackTracePort) &&
+ fallbackTraceTarget?.clientId !== undefined &&
+ fallbackTraceTarget?.contextId !== undefined
+ ) {
+ state.performance.activeTraceTarget = {
+ port: fallbackTracePort,
+ clientId: String(fallbackTraceTarget.clientId),
+ contextId: String(fallbackTraceTarget.contextId),
+ };
+ }
+ }
+ state.performance.traceActive = false;
+ state.performance.traceResultPending = false;
+ state.performance.traceStateUnknown = state.performance.activeTraceTarget !== null || persistedTraceMayBeActive;
if (saved.profileDuration !== undefined) elements.profileDurationInput.value = String(saved.profileDuration);
if (saved.profileContextId !== undefined) elements.profileContextSelect.value = String(saved.profileContextId);
+ if (saved.providerTab === 'storage' || saved.providerTab === 'sql') state.providers.activeTab = saved.providerTab;
+ if (saved.settingsGroupId !== undefined && saved.settingsGroupId !== null) {
+ state.settings.selectedGroupId = String(saved.settingsGroupId);
+ }
return true;
}
diff --git a/npm_modules/cli/debugger/debugger-settings.js b/npm_modules/cli/debugger/debugger-settings.js
new file mode 100644
index 000000000..46d819a50
--- /dev/null
+++ b/npm_modules/cli/debugger/debugger-settings.js
@@ -0,0 +1,209 @@
+// Application-published debug settings UI.
+function debugSettingsSnapshotFromResult(result) {
+ return result?.handled === true && result.status === 'handled' && result.data && typeof result.data === 'object'
+ ? result.data
+ : null;
+}
+
+function acceptDebugSettingsResult(result, request) {
+ if (!debuggerToolRequestIsCurrent(state.settings, request, result)) return false;
+ const snapshot = debugSettingsSnapshotFromResult(result);
+ state.settings.snapshot = snapshot;
+ state.settings.error = snapshot ? null : result?.message || 'The target does not publish debug settings.';
+ state.settings.targetKey = request.targetKey;
+ const groups = Array.isArray(snapshot?.groups) ? snapshot.groups : [];
+ if (!groups.some(group => group.id === state.settings.selectedGroupId)) {
+ state.settings.selectedGroupId = groups[0]?.id || null;
+ }
+ renderDebugSettings();
+ return true;
+}
+
+function applyDebugSettingsResult(result, params) {
+ const request = beginDebuggerActionResult(state.settings, params, result);
+ if (!request) return false;
+ return acceptDebugSettingsResult(result, request);
+}
+
+async function refreshDebugSettings(options = {}) {
+ const request = beginDebuggerToolRequest(state.settings);
+ state.settings.loading = true;
+ state.settings.error = null;
+ state.settings.targetKey = request.targetKey;
+ renderDebugSettings();
+ if (!request.targetKey || !hasSelectedLiveTarget()) {
+ if (debuggerToolRequestIsCurrent(state.settings, request)) {
+ state.settings.loading = false;
+ state.settings.snapshot = null;
+ state.settings.error = 'Attach to a live Valdi target to inspect application settings.';
+ renderDebugSettings();
+ }
+ return;
+ }
+ try {
+ const result = await apiGet('/api/debugger/settings', request.params, { timeoutMs: 7000 });
+ if (!acceptDebugSettingsResult(result, request)) return;
+ if (!options.silent) addLog(result.handled ? 'info' : 'warn', 'settings', result.message || 'Refreshed settings.');
+ } catch (error) {
+ if (!debuggerToolRequestIsCurrent(state.settings, request)) return;
+ state.settings.snapshot = null;
+ state.settings.error = error.message;
+ if (!options.silent) addLog('error', 'settings', error.message);
+ } finally {
+ if (debuggerToolRequestIsCurrent(state.settings, request)) {
+ state.settings.loading = false;
+ renderDebugSettings();
+ }
+ }
+}
+
+function selectedDebugSettingsGroup() {
+ const groups = Array.isArray(state.settings.snapshot?.groups) ? state.settings.snapshot.groups : [];
+ return groups.find(group => group.id === state.settings.selectedGroupId) || null;
+}
+
+function selectedDebugSetting(settingId) {
+ const group = selectedDebugSettingsGroup();
+ return Array.isArray(group?.settings) ? group.settings.find(setting => setting.id === settingId) || null : null;
+}
+
+function debugSettingOptionIndex(setting, value) {
+ return Array.isArray(setting.options) ? setting.options.findIndex(option => option.value === value) : -1;
+}
+
+function isDebugSettingPrimitive(value) {
+ return typeof value === 'boolean' || typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value));
+}
+
+function unsupportedDebugSettingControl(message) {
+ return `Unsupported ${escapeHtml(message)} `;
+}
+
+function renderDebugSettingControl(setting) {
+ const attributes = `data-setting-id="${escapeHtml(setting.id)}" data-setting-kind="${escapeHtml(setting.kind)}"`;
+ if (setting.kind === 'toggle') {
+ if (typeof setting.value !== 'boolean') return unsupportedDebugSettingControl('toggle value');
+ return ` `;
+ }
+ if (setting.kind === 'select') {
+ if (
+ !Array.isArray(setting.options) ||
+ setting.options.length === 0 ||
+ setting.options.some(option => typeof option?.label !== 'string' || !isDebugSettingPrimitive(option?.value))
+ ) {
+ return unsupportedDebugSettingControl('select declaration');
+ }
+ const selectedIndex = debugSettingOptionIndex(setting, setting.value);
+ if (selectedIndex < 0) return unsupportedDebugSettingControl('select value');
+ const options = setting.options
+ .map((option, index) => `${escapeHtml(option.label)} `)
+ .join('');
+ return `${options} `;
+ }
+ if (setting.kind === 'text') {
+ if (typeof setting.value !== 'string') return unsupportedDebugSettingControl('text value');
+ return ` `;
+ }
+ if (setting.kind === 'number') {
+ if (typeof setting.value !== 'number' || !Number.isFinite(setting.value)) {
+ return unsupportedDebugSettingControl('number value');
+ }
+ return ` `;
+ }
+ return `Unsupported setting kind: ${escapeHtml(setting.kind || 'unknown')} `;
+}
+
+function renderDebugSettings() {
+ if (!elements.settingsContent || !elements.settingsStatusPill) return;
+ const snapshot = state.settings.snapshot;
+ const groups = Array.isArray(snapshot?.groups) ? snapshot.groups : [];
+ elements.settingsRefreshButton.disabled = state.settings.loading;
+ elements.settingsStatusPill.textContent = state.settings.loading ? 'Loading' : state.settings.error ? 'Unavailable' : snapshot ? 'Ready' : 'Idle';
+ elements.settingsStatusPill.className = `source-pill ${snapshot && !state.settings.error ? 'live' : ''}`;
+ elements.settingsGroupSelect.innerHTML = groups
+ .map(group => `${escapeHtml(group.label)} `)
+ .join('');
+ elements.settingsGroupSelect.disabled = state.settings.loading || groups.length === 0;
+
+ if (state.settings.loading && !snapshot) {
+ elements.settingsContent.innerHTML = 'Loading application-published settings…
';
+ return;
+ }
+ if (state.settings.error) {
+ elements.settingsContent.innerHTML = `${escapeHtml(state.settings.error)}
`;
+ return;
+ }
+ const group = selectedDebugSettingsGroup();
+ if (!group) {
+ elements.settingsContent.innerHTML = 'The selected application has not published any debug settings.
';
+ return;
+ }
+ elements.settingsContent.innerHTML = (group.settings || [])
+ .map(setting => {
+ const changed = setting.value !== setting.defaultValue;
+ return `${escapeHtml(setting.label)}
${setting.description ? `
${escapeHtml(setting.description)}
` : ''}
${renderDebugSettingControl(setting)}Reset
`;
+ })
+ .join('');
+}
+
+function readDebugSettingInput(element) {
+ const setting = selectedDebugSetting(element.dataset.settingId);
+ if (!setting || setting.kind !== element.dataset.settingKind) return { error: 'The setting changed; refresh and try again.' };
+ if (setting.kind === 'toggle') return { value: element.checked === true };
+ if (setting.kind === 'select') {
+ const index = Number(element.value);
+ if (!Array.isArray(setting.options) || !Number.isInteger(index) || index < 0 || index >= setting.options.length) {
+ return { error: 'Select a declared option.' };
+ }
+ const value = setting.options[index]?.value;
+ return isDebugSettingPrimitive(value) ? { value } : { error: 'Select a declared primitive option.' };
+ }
+ if (setting.kind === 'text') return { value: element.value };
+ if (setting.kind === 'number') {
+ if (String(element.value).trim() === '') return { error: 'Enter a finite number.' };
+ const value = Number(element.value);
+ return Number.isFinite(value) ? { value } : { error: 'Enter a finite number.' };
+ }
+ return { error: `Unsupported setting kind: ${String(setting.kind)}` };
+}
+
+async function changeDebugSetting(action, settingId, value) {
+ if (!state.settings.selectedGroupId) return;
+ await requestDebuggerAction(action === 'set' ? 'setDebugSetting' : 'resetDebugSetting', {
+ ...getSelectedTargetParams(),
+ groupId: state.settings.selectedGroupId,
+ settingId,
+ ...(action === 'set' ? { value } : {}),
+ });
+}
+
+function maybeRefreshDebuggerTools(section) {
+ if (section === 'data' && (!state.providers.registry || state.providers.targetKey !== debuggerTargetKey())) {
+ void refreshDebuggerProviders({ silent: true });
+ }
+ if (section === 'settings' && (!state.settings.snapshot || state.settings.targetKey !== debuggerTargetKey())) {
+ void refreshDebugSettings({ silent: true });
+ }
+}
+
+function resetDebuggerToolsForTarget() {
+ state.providers.generation += 1;
+ state.providers.error = null;
+ state.providers.loading = false;
+ state.providers.registry = null;
+ state.providers.storage = null;
+ state.providers.sql = null;
+ state.providers.sqlLoading = false;
+ state.providers.sqlTable = null;
+ state.providers.selectedDatabaseId = null;
+ state.providers.selectedSqlProviderId = null;
+ state.providers.selectedStorageProviderId = null;
+ state.providers.selectedTable = null;
+ state.providers.sqlOffset = 0;
+ state.providers.targetKey = null;
+ state.settings.generation += 1;
+ state.settings.error = null;
+ state.settings.loading = false;
+ state.settings.snapshot = null;
+ state.settings.targetKey = null;
+}
diff --git a/npm_modules/cli/debugger/debugger-state.js b/npm_modules/cli/debugger/debugger-state.js
index 0d8bb024c..ce0af8b74 100644
--- a/npm_modules/cli/debugger/debugger-state.js
+++ b/npm_modules/cli/debugger/debugger-state.js
@@ -51,15 +51,49 @@ const state = {
lastDebuggerRevision: 0,
rootSnapshotImage: null,
rootSnapshotRequestId: 0,
+ inputRefreshTimers: new Map(),
manualDetach: false,
followLatestTarget: true,
exportObjectUrl: null,
performance: {
+ traceActive: false,
+ traceCapturePending: false,
+ traceResultPending: false,
+ traceStateUnknown: false,
+ activeTraceTarget: null,
+ traceSupported: true,
+ lastTrace: null,
profileActive: false,
activeProfileContextId: null,
lastProfile: null,
profileContexts: [],
},
+ providers: {
+ activeTab: 'storage',
+ error: null,
+ generation: 0,
+ loading: false,
+ registry: null,
+ selectedDatabaseId: null,
+ selectedSqlProviderId: null,
+ selectedStorageProviderId: null,
+ selectedTable: null,
+ sql: null,
+ sqlLimit: 50,
+ sqlLoading: false,
+ sqlOffset: 0,
+ sqlTable: null,
+ storage: null,
+ targetKey: null,
+ },
+ settings: {
+ error: null,
+ generation: 0,
+ loading: false,
+ selectedGroupId: null,
+ snapshot: null,
+ targetKey: null,
+ },
};
const elements = {
@@ -96,6 +130,15 @@ const elements = {
snapshotButton: document.getElementById('snapshotButton'),
copyPreviewButton: document.getElementById('copyPreviewButton'),
autoRefreshToggle: document.getElementById('autoRefreshToggle'),
+ rendererTracingToggle: document.getElementById('rendererTracingToggle'),
+ traceDurationInput: document.getElementById('traceDurationInput'),
+ traceStartButton: document.getElementById('traceStartButton'),
+ traceStopButton: document.getElementById('traceStopButton'),
+ traceCaptureButton: document.getElementById('traceCaptureButton'),
+ traceExportButton: document.getElementById('traceExportButton'),
+ traceStatusPill: document.getElementById('traceStatusPill'),
+ traceSummary: document.getElementById('traceSummary'),
+ traceEvents: document.getElementById('traceEvents'),
profileContextSelect: document.getElementById('profileContextSelect'),
profileRefreshButton: document.getElementById('profileRefreshButton'),
profileDurationInput: document.getElementById('profileDurationInput'),
@@ -105,6 +148,13 @@ const elements = {
profileExportButton: document.getElementById('profileExportButton'),
profileStatusPill: document.getElementById('profileStatusPill'),
profileSummary: document.getElementById('profileSummary'),
+ providerContent: document.getElementById('providerContent'),
+ providerRefreshButton: document.getElementById('providerRefreshButton'),
+ providerStatusPill: document.getElementById('providerStatusPill'),
+ settingsContent: document.getElementById('settingsContent'),
+ settingsGroupSelect: document.getElementById('settingsGroupSelect'),
+ settingsRefreshButton: document.getElementById('settingsRefreshButton'),
+ settingsStatusPill: document.getElementById('settingsStatusPill'),
};
function hasSnapshotTree(snapshot = state.snapshot) {
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/debugger.css b/npm_modules/cli/debugger/debugger.css
index 33daf2a1d..6f6bb6fbf 100644
--- a/npm_modules/cli/debugger/debugger.css
+++ b/npm_modules/cli/debugger/debugger.css
@@ -40,6 +40,7 @@
--device-shell: #10110e;
--screen-bg: #fffefb;
--overlay-label-bg: #10110e;
+ --trace-detail-bg: #fffdf7;
--console-bg: #11110d;
--console-surface: #1e1d17;
--console-surface-2: #29271f;
@@ -81,6 +82,7 @@
--device-shell: #120c16;
--screen-bg: #fff8ef;
--overlay-label-bg: #120c16;
+ --trace-detail-bg: #fff7ed;
--console-bg: #130d18;
--console-surface: #211629;
--console-surface-2: #2f1d34;
@@ -1034,6 +1036,217 @@ video.valdi-html-node {
line-height: 1.4;
}
+.tool-panel {
+ min-height: 0;
+ overflow: auto;
+ padding: 18px;
+ align-content: start;
+ gap: 14px;
+}
+
+.section-heading.with-actions {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: 12px;
+}
+
+.tool-actions,
+.provider-tabs,
+.sql-pagination {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.tool-actions {
+ justify-content: flex-end;
+}
+
+.tool-actions select {
+ min-width: 180px;
+}
+
+.provider-tabs {
+ border-bottom: 1px solid var(--line);
+}
+
+.provider-tabs button {
+ border-radius: 7px 7px 0 0;
+ border-bottom-color: transparent;
+}
+
+.provider-tabs button.active {
+ color: var(--accent-strong);
+ background: var(--surface-2);
+ border-color: var(--line);
+ border-bottom-color: var(--surface-2);
+}
+
+.tool-content {
+ min-width: 0;
+ display: grid;
+ gap: 12px;
+}
+
+.provider-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface-2);
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.provider-status.available {
+ border-color: var(--good-line);
+ background: var(--good-bg);
+ color: var(--text);
+}
+
+.storage-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 10px;
+}
+
+.storage-card,
+.data-entry {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface);
+}
+
+.storage-card > summary,
+.data-entry > summary {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ cursor: pointer;
+ padding: 10px;
+}
+
+.storage-card > summary span,
+.data-entry > summary span,
+.provider-note {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.storage-card > .data-entry {
+ margin: 0 10px 8px;
+ background: var(--surface-2);
+}
+
+.data-entry .codebox {
+ margin: 0 8px 8px;
+ max-height: 180px;
+}
+
+.provider-note {
+ padding: 0 10px 10px;
+ line-height: 1.45;
+}
+
+.sql-controls {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 10px;
+}
+
+.sql-controls label {
+ display: grid;
+ gap: 5px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.sql-table-wrap {
+ overflow: auto;
+ max-height: 460px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+}
+
+.sql-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-family: var(--mono);
+ font-size: 11px;
+}
+
+.sql-table th,
+.sql-table td {
+ max-width: 320px;
+ padding: 7px 9px;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sql-table th {
+ position: sticky;
+ top: 0;
+ background: var(--surface-2);
+ z-index: 1;
+}
+
+.sql-pagination {
+ justify-content: flex-end;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.settings-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(220px, auto);
+ align-items: center;
+ gap: 16px;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface);
+}
+
+.settings-label {
+ font-size: 13px;
+ font-weight: 800;
+ margin-bottom: 4px;
+}
+
+.settings-control {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.settings-control input[type='text'],
+.settings-control input[type='number'],
+.settings-control select {
+ min-width: 180px;
+ max-width: 300px;
+}
+
+@media (max-width: 760px) {
+ .section-heading.with-actions,
+ .settings-row {
+ grid-template-columns: 1fr;
+ }
+
+ .tool-actions,
+ .settings-control {
+ justify-content: flex-start;
+ }
+}
+
.section-panel:not(.active) {
display: none;
}
@@ -1107,6 +1320,84 @@ video.valdi-html-node {
color: var(--text);
}
+.trace-events {
+ display: grid;
+ gap: 4px;
+ max-height: 190px;
+ overflow: auto;
+ border-top: 1px solid var(--line);
+ padding-top: 8px;
+}
+
+.trace-events:empty {
+ display: none;
+}
+
+.trace-events-header,
+.trace-event-summary {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 72px 72px;
+ gap: 8px;
+ align-items: baseline;
+}
+
+.trace-events-header {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.trace-event-row {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--surface);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
+ font-size: 11px;
+}
+
+.trace-event-summary {
+ padding: 5px 6px;
+ cursor: pointer;
+ list-style: none;
+}
+
+.trace-event-summary::-webkit-details-marker {
+ display: none;
+}
+
+.trace-event-name::before {
+ content: '>';
+ display: inline-block;
+ width: 12px;
+ color: var(--muted);
+}
+
+.trace-event-row[open] .trace-event-name::before {
+ content: 'v';
+}
+
+.trace-event-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.trace-event-meta {
+ color: var(--muted);
+ text-align: right;
+}
+
+.trace-event-details {
+ margin: 0;
+ border-top: 1px solid var(--line);
+ padding: 7px 8px 8px;
+ background: var(--trace-detail-bg);
+ color: var(--text);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
.tabs {
display: grid;
grid-template-columns: repeat(5, 1fr);
diff --git a/npm_modules/cli/debugger/devtools-panel.css b/npm_modules/cli/debugger/devtools-panel.css
new file mode 100644
index 000000000..59fe6575b
--- /dev/null
+++ b/npm_modules/cli/debugger/devtools-panel.css
@@ -0,0 +1,1318 @@
+/* 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:disabled {
+ background: transparent;
+ color: var(--muted);
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.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-select-label {
+ color: var(--muted);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.target-select {
+ width: min(320px, 42vw);
+ min-width: 120px;
+ height: 22px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ background: var(--surface);
+ color: var(--text);
+ font: inherit;
+ text-overflow: ellipsis;
+}
+
+.target-select[hidden],
+.target-select-label[hidden] {
+ display: none;
+}
+
+.target-picker-status {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--muted);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.target-metadata {
+ overflow: hidden;
+ color: var(--muted);
+ 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);
+}
+
+.component-row .tag-name {
+ color: var(--accent);
+ font-weight: 600;
+}
+
+.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;
+}
+
+.component-properties {
+ margin-bottom: 12px;
+ border-bottom: 1px solid var(--border);
+}
+
+.component-property-editor {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 6px;
+ margin: 2px 0;
+}
+
+.component-property-label {
+ display: flex;
+ min-width: 0;
+ gap: 4px;
+ align-items: center;
+}
+
+.component-property-input {
+ min-width: 0;
+ flex: 1;
+ border: 1px solid var(--border);
+ border-radius: 2px;
+ background: var(--surface);
+ color: var(--text);
+ font: inherit;
+}
+
+.component-property-input:focus {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+.component-property-string-input {
+ resize: vertical;
+ white-space: pre;
+}
+
+.component-property-apply {
+ border: 1px solid var(--border);
+ border-radius: 2px;
+ background: var(--surface-hover);
+ color: var(--text);
+ font: inherit;
+}
+
+.component-property-error {
+ margin-bottom: 6px;
+ color: var(--error);
+ overflow-wrap: anywhere;
+}
+
+.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;
+}
+
+.runtime-state-toolbar {
+ justify-content: flex-start;
+ gap: 9px;
+}
+
+.runtime-state-summary {
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 400;
+ white-space: nowrap;
+}
+
+.runtime-state-filter {
+ width: min(320px, 48vw);
+ min-width: 120px;
+ height: 23px;
+ margin-left: auto;
+ padding: 0 7px;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ outline: none;
+ background: var(--background);
+ color: var(--text);
+ font-size: 11px;
+}
+
+.runtime-state-filter:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
+}
+
+.runtime-state-content {
+ padding: 0;
+ font-family: var(--mono);
+ font-size: 11px;
+}
+
+.runtime-state-component {
+ position: relative;
+ border-bottom: 1px solid var(--border-soft);
+}
+
+.runtime-state-component-details {
+ min-width: 0;
+}
+
+.runtime-state-component-summary,
+.runtime-state-value-summary {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ list-style: none;
+ cursor: pointer;
+}
+
+.runtime-state-component-summary {
+ min-height: 34px;
+ padding: 4px 68px 4px 9px;
+ gap: 7px;
+}
+
+.runtime-state-component-summary::-webkit-details-marker,
+.runtime-state-value-summary::-webkit-details-marker {
+ display: none;
+}
+
+.runtime-state-component-summary:hover,
+.runtime-state-value-summary:hover,
+.runtime-state-inspect:hover,
+.runtime-state-inspect:focus-visible {
+ background: var(--surface-hover);
+}
+
+.runtime-state-disclosure {
+ width: 10px;
+ flex: 0 0 auto;
+ color: var(--muted);
+}
+
+.runtime-state-component-details[open] > .runtime-state-component-summary > .runtime-state-disclosure,
+details[open] > .runtime-state-value-summary > .runtime-state-disclosure {
+ transform: rotate(90deg);
+}
+
+.runtime-state-component-name {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--tag);
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.runtime-state-component-key,
+.runtime-state-component-description,
+.runtime-state-limit {
+ color: var(--muted);
+}
+
+.runtime-state-component-key,
+.runtime-state-component-description {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.runtime-state-component-description {
+ margin-left: auto;
+}
+
+.runtime-state-inspect {
+ position: absolute;
+ top: 5px;
+ right: 8px;
+ flex: 0 0 auto;
+ padding: 3px 6px;
+ border: 0;
+ border-radius: 3px;
+ background: transparent;
+ color: var(--accent);
+}
+
+.runtime-state-component-body {
+ padding: 4px 10px 9px 25px;
+ border-top: 1px solid var(--border-soft);
+}
+
+.runtime-state-value-row {
+ min-height: 20px;
+ overflow-wrap: anywhere;
+}
+
+.runtime-state-value-details {
+ margin-left: 10px;
+}
+
+.runtime-state-value-summary {
+ min-height: 20px;
+ gap: 4px;
+}
+
+.runtime-state-value-children {
+ margin-left: 10px;
+ padding-left: 8px;
+ border-left: 1px solid var(--border-soft);
+}
+
+.runtime-state-value-key {
+ color: var(--attribute);
+}
+
+.runtime-state-value-string {
+ color: var(--string);
+}
+
+.runtime-state-value-number,
+.runtime-state-value-boolean,
+.runtime-state-value-null {
+ color: var(--number);
+}
+
+.runtime-state-value-description {
+ color: var(--muted);
+}
+
+.runtime-state-raw {
+ max-height: 260px;
+ margin: 0;
+ overflow: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.runtime-state-inspector {
+ margin-bottom: 12px;
+}
+
+.runtime-state-limit {
+ padding: 7px 0;
+ font-style: italic;
+}
+
+.breadcrumb-bar {
+ padding: 0 8px;
+ border-top: 1px solid var(--border);
+ 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);
+}
+
+.utility-section {
+ min-height: 0;
+ grid-template-rows: 32px minmax(0, 1fr);
+}
+
+.section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 10px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+ font-weight: 500;
+}
+
+.text-button {
+ padding: 4px 7px;
+ border: 0;
+ background: transparent;
+ color: var(--accent);
+}
+
+.utility-content {
+ min-height: 0;
+ padding: 12px;
+ overflow: auto;
+}
+
+.metric-grid {
+ display: grid;
+ margin-bottom: 15px;
+ gap: 9px;
+ grid-template-columns: repeat(auto-fit, minmax(125px, 1fr));
+}
+
+.metric-card {
+ min-width: 0;
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+}
+
+.metric-label {
+ color: var(--muted);
+}
+
+.metric-value {
+ margin-top: 6px;
+ font-family: var(--mono);
+ font-size: 18px;
+}
+
+.metric-card-chart {
+ padding-bottom: 7px;
+}
+
+.performance-sparkline {
+ display: block;
+ width: 100%;
+ height: 30px;
+ margin-top: 7px;
+ --graph-color: var(--accent);
+}
+
+.performance-sparkline.main-thread {
+ --graph-color: var(--warning);
+}
+
+.performance-sparkline.resources {
+ --graph-color: var(--tag);
+}
+
+.performance-sparkline-baseline {
+ fill: none;
+ stroke: var(--border-soft);
+ vector-effect: non-scaling-stroke;
+}
+
+.performance-sparkline-area {
+ fill: color-mix(in srgb, var(--graph-color) 14%, transparent);
+}
+
+.performance-sparkline-line {
+ fill: none;
+ stroke: var(--graph-color);
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.5;
+ vector-effect: non-scaling-stroke;
+}
+
+.performance-recorder-card {
+ margin: 0 0 12px;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--background);
+}
+
+.performance-recorder-heading,
+.performance-recorder-controls,
+.performance-legend {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.performance-recorder-heading {
+ justify-content: space-between;
+}
+
+.performance-recorder-heading > div {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 7px;
+}
+
+.performance-recorder-controls {
+ flex-wrap: wrap;
+ margin: 10px 0;
+}
+
+.performance-status,
+.performance-tracing-badge {
+ padding: 2px 7px;
+ border-radius: 10px;
+ background: var(--surface);
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.performance-status.recording {
+ background: color-mix(in srgb, var(--error) 12%, var(--background));
+ color: var(--error);
+}
+
+.performance-tracing-badge.enabled {
+ background: color-mix(in srgb, var(--success) 12%, var(--background));
+ color: var(--success);
+}
+
+.performance-tracing-badge.disabled {
+ background: color-mix(in srgb, var(--warning) 13%, var(--background));
+ color: var(--warning);
+}
+
+.performance-button {
+ padding: 5px 8px;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ background: var(--surface);
+}
+
+.performance-button:hover:not(:disabled) {
+ background: var(--surface-hover);
+}
+
+.performance-button.primary {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: var(--background);
+}
+
+.performance-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.performance-duration {
+ display: inline-flex;
+ align-items: center;
+ color: var(--muted);
+ gap: 4px;
+}
+
+.performance-duration input {
+ width: 55px;
+ padding: 3px 4px;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ background: var(--background);
+ color: var(--text);
+}
+
+.performance-notice,
+.performance-error {
+ margin: 9px 0;
+ padding: 8px;
+ border: 1px solid color-mix(in srgb, var(--warning) 45%, var(--border));
+ border-radius: 3px;
+ background: color-mix(in srgb, var(--warning) 7%, var(--background));
+ line-height: 1.5;
+}
+
+.performance-notice .performance-button {
+ margin-left: 6px;
+}
+
+.performance-error {
+ border-color: color-mix(in srgb, var(--error) 45%, var(--border));
+ background: color-mix(in srgb, var(--error) 7%, var(--background));
+ color: var(--error);
+}
+
+.performance-caption {
+ margin: 8px 0;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.performance-trace-filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ margin: 11px 0 6px;
+ gap: 8px;
+}
+
+.performance-scope-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.performance-scope-button {
+ padding: 4px 7px;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ background: var(--surface);
+ color: var(--text);
+ font-size: 10px;
+}
+
+.performance-scope-button.selected {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 12%, var(--background));
+ color: var(--accent);
+}
+
+.performance-scope-button span {
+ margin-left: 3px;
+ color: var(--muted);
+}
+
+.performance-trace-search {
+ min-width: 175px;
+ flex: 1 1 175px;
+ padding: 4px 6px;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ background: var(--background);
+ color: var(--text);
+ font-size: 10px;
+}
+
+.performance-legend {
+ flex-wrap: wrap;
+ margin: 11px 0 7px;
+}
+
+.performance-legend-item {
+ display: inline-flex;
+ align-items: center;
+ color: var(--muted);
+ font-size: 10px;
+ gap: 4px;
+}
+
+.performance-legend-item::before {
+ width: 8px;
+ height: 8px;
+ border-radius: 2px;
+ background: var(--lane-color);
+ content: '';
+}
+
+.performance-event-bar.valdi,
+.performance-legend-item.valdi {
+ --lane-color: var(--accent);
+}
+
+.performance-event-bar.script,
+.performance-legend-item.script {
+ --lane-color: var(--warning);
+}
+
+.performance-event-bar.layout,
+.performance-legend-item.layout {
+ --lane-color: var(--tag);
+}
+
+.performance-event-bar.paint,
+.performance-legend-item.paint {
+ --lane-color: var(--success);
+}
+
+.performance-event-bar.frames,
+.performance-legend-item.frames {
+ --lane-color: var(--attribute);
+}
+
+.performance-event-bar.gc,
+.performance-legend-item.gc {
+ --lane-color: var(--error);
+}
+
+.performance-timeline {
+ max-height: 350px;
+ overflow: auto;
+ border: 1px solid var(--border-soft);
+ border-radius: 3px;
+}
+
+.performance-timeline-row {
+ display: grid;
+ min-height: 21px;
+ align-items: center;
+ border-bottom: 1px solid var(--border-soft);
+ font-family: var(--mono);
+ font-size: 10px;
+ gap: 6px;
+ grid-template-columns: minmax(120px, 30%) minmax(80px, 1fr) 62px;
+}
+
+.performance-timeline-row:last-child {
+ border-bottom: 0;
+}
+
+.performance-event-name,
+.performance-event-duration {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.performance-event-name {
+ padding-left: 6px;
+}
+
+.performance-event-duration {
+ padding-right: 5px;
+ color: var(--muted);
+ text-align: right;
+}
+
+.performance-event-track {
+ position: relative;
+ height: 15px;
+ background: color-mix(in srgb, var(--surface) 70%, transparent);
+}
+
+.performance-event-bar {
+ position: absolute;
+ top: 3px;
+ bottom: 3px;
+ min-width: 2px;
+ border-radius: 2px;
+ background: var(--lane-color);
+}
+
+.utility-heading {
+ margin: 13px 0 7px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.performance-results-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-family: var(--mono);
+ font-size: 10px;
+}
+
+.performance-results-table th,
+.performance-results-table td {
+ padding: 5px 6px;
+ border-bottom: 1px solid var(--border-soft);
+ text-align: left;
+}
+
+.performance-results-table th {
+ color: var(--muted);
+ font-weight: 500;
+}
+
+.performance-results-table th:not(:first-child),
+.performance-results-table td:not(:first-child) {
+ text-align: right;
+}
+
+.performance-navigation {
+ margin: 10px 0;
+ color: var(--muted);
+}
+
+.performance-navigation summary {
+ cursor: pointer;
+}
+
+.console-section {
+ min-height: 0;
+ grid-template-rows: 32px 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.warn {
+ background: color-mix(in srgb, var(--warning) 8%, var(--background));
+ color: var(--warning);
+}
+
+.console-entry.debug {
+ color: var(--muted);
+}
+
+.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;
+ }
+
+ .target-picker-status {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ border: 0;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+
+ .target-select {
+ width: min(230px, 52vw);
+ }
+}
diff --git a/npm_modules/cli/debugger/devtools-panel.html b/npm_modules/cli/debugger/devtools-panel.html
new file mode 100644
index 000000000..6ec95a213
--- /dev/null
+++ b/npm_modules/cli/debugger/devtools-panel.html
@@ -0,0 +1,273 @@
+
+
+
+
+
+ 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..825b644d3
--- /dev/null
+++ b/npm_modules/cli/debugger/devtools-panel.js
@@ -0,0 +1,3329 @@
+const query = new URLSearchParams(window.location.search);
+const MAX_REGISTRY_CAPABILITIES = 32;
+const MAX_REGISTRY_ENTRIES = 256;
+const MAX_REGISTRY_ID_CHARACTERS = 512;
+const MAX_REGISTRY_LABEL_CHARACTERS = 96;
+const MAX_REGISTRY_LABEL_TOTAL_CHARACTERS = 180;
+const MAX_CONSOLE_ENTRIES = 500;
+const MAX_CONSOLE_ENTRY_CHARACTERS = 50_000;
+const MAX_CONSOLE_HISTORY_ENTRIES = 100;
+const MAX_PERFORMANCE_SAMPLES = 120;
+const MAX_PERFORMANCE_TIMELINE_ROWS = 120;
+const MAX_PERFORMANCE_SUMMARY_ROWS = 12;
+const MAX_RUNTIME_STATE_CHARACTERS = 65_536;
+const MAX_RUNTIME_STATE_COMPONENT_ROWS = 500;
+const MAX_RUNTIME_STATE_CONTAINER_ENTRIES = 100;
+const MAX_RUNTIME_STATE_DEPTH = 12;
+const MAX_RUNTIME_STATE_KEY_CHARACTERS = 1_024;
+const MAX_RUNTIME_STATE_TOKENS = 2_000;
+const MAX_RUNTIME_STATE_VALUE_ROWS = 1_000;
+const RUNTIME_STATE_SOURCE_NATIVE = 'native';
+const RUNTIME_STATE_SOURCE_WEB = 'web';
+const COMPONENT_PROPERTY_TOKEN_PATTERN = /^[0-9a-f]{32}$/;
+const COMPONENT_PROPERTY_EDIT_ERROR = 'The component property edit is stale or invalid.';
+const COMPONENT_PROPERTY_EDIT_PROTOCOL_VERSION = 1;
+const SNAPSHOT_REFRESH_APPLIED = 'applied';
+const SNAPSHOT_REFRESH_RUNTIME_STATE_DEFERRED = 'runtime-state-deferred';
+const SNAPSHOT_REFRESH_SKIPPED = 'skipped';
+const FORBIDDEN_COMPONENT_PROPERTY_NAMES = new Set(['__proto__', 'children', 'constructor', 'prototype']);
+const componentPropertyEditorBindings = new WeakMap();
+const runtimeStateInspectBindings = new WeakMap();
+const runtimeStateParseCache = new WeakMap();
+const MANUAL_WEB_CAPABILITIES = new Set([
+ 'component-properties',
+ 'components',
+ 'console',
+ 'highlight',
+ 'performance',
+ 'snapshot',
+ 'storage',
+]);
+
+function parseLaunchIdentity(searchParams) {
+ const targetIds = searchParams.getAll('targetId');
+ const inspectedUrls = searchParams.getAll('inspectedUrl');
+ const targetNonces = searchParams.getAll('targetNonce');
+ if (targetIds.length > 1 || inspectedUrls.length > 1 || targetNonces.length > 1) {
+ return { error: 'The DevTools URL contains a duplicated target identity.' };
+ }
+
+ const targetId = targetIds[0];
+ const inspectedPageUrl = inspectedUrls[0];
+ const targetNonce = targetNonces[0];
+ if (targetId !== undefined) {
+ if (
+ targetId.length === 0 ||
+ targetId.length > MAX_REGISTRY_ID_CHARACTERS ||
+ /[\u0000-\u001f\u007f]/.test(targetId) ||
+ inspectedPageUrl !== undefined ||
+ targetNonce !== undefined
+ ) {
+ return { error: 'Direct DevTools requires one bounded targetId and no inspected-page identity.' };
+ }
+ return { mode: 'target-id', requestedTargetId: targetId };
+ }
+
+ if (
+ inspectedPageUrl === undefined ||
+ inspectedPageUrl.length === 0 ||
+ targetNonce === undefined ||
+ targetNonce.length === 0
+ ) {
+ return { error: 'Inspected-page DevTools requires both inspectedUrl and targetNonce.' };
+ }
+ return { inspectedUrl: inspectedPageUrl, mode: 'inspected-page', targetNonce };
+}
+
+const launchIdentity = parseLaunchIdentity(query);
+const inspectedUrl = launchIdentity.mode === 'inspected-page' ? launchIdentity.inspectedUrl : null;
+const inspectedTargetNonce = launchIdentity.mode === 'inspected-page' ? launchIdentity.targetNonce : null;
+
+const state = {
+ target: null,
+ targetGeneration: 0,
+ targetSwitchMessage: null,
+ registryTargets: [],
+ registryError: null,
+ registryGeneration: 0,
+ registryRequestGeneration: 0,
+ registryPending: false,
+ connectionRequestGeneration: 0,
+ initialTargetResolutionPending: launchIdentity.mode === 'target-id',
+ unavailableTargetId: null,
+ snapshot: null,
+ activeSection: 'elements',
+ activeDetail: 'styles',
+ selectedNodeId: null,
+ remoteSelectedNodeId: null,
+ expandedNodeIds: new Set(),
+ search: '',
+ autoRefresh: true,
+ refreshTimer: null,
+ refreshPending: false,
+ snapshotRequestCompletion: null,
+ snapshotGeneration: 0,
+ snapshotRequestGeneration: 0,
+ hoveredNodeId: null,
+ hoveredSnapshotGeneration: 0,
+ highlightTimer: null,
+ highlightIntentGeneration: 0,
+ highlightMayBeActive: false,
+ highlightRequestTail: Promise.resolve(),
+ consoleEntries: [],
+ consoleEntryKeys: new Set(),
+ consoleHistory: [],
+ consoleHistoryIndex: 0,
+ consoleStream: null,
+ consoleStreamTargetKey: null,
+ performance: {
+ data: null,
+ durationSeconds: 3,
+ error: null,
+ lastTrace: null,
+ navigationExpanded: false,
+ operationGeneration: 0,
+ ownerIdentity: null,
+ pending: false,
+ rendererTracingEnabled: false,
+ requestGeneration: 0,
+ samples: [],
+ snapshotPending: false,
+ traceActive: false,
+ traceScope: 'valdi',
+ traceSearch: '',
+ },
+ componentPropertyEdit: {
+ error: null,
+ focused: false,
+ operationGeneration: 0,
+ pending: false,
+ },
+ runtimeState: {
+ expandedComponents: new Set(),
+ expandedInspectorValues: new Set(),
+ expandedMainValues: new Set(),
+ inspectGeneration: 0,
+ inspectorNodeId: null,
+ search: '',
+ },
+ 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'),
+ targetSelectLabel: document.getElementById('targetSelectLabel'),
+ targetSelect: document.getElementById('targetSelect'),
+ targetName: document.getElementById('targetName'),
+ targetMetadata: document.getElementById('targetMetadata'),
+ targetPickerStatus: document.getElementById('targetPickerStatus'),
+ autoRefreshToggle: document.getElementById('autoRefreshToggle'),
+ refreshButton: document.getElementById('refreshButton'),
+ treeFilter: document.getElementById('treeFilter'),
+ 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'),
+ clearConsoleButton: document.getElementById('clearConsoleButton'),
+ consoleMessages: document.getElementById('consoleMessages'),
+ consoleForm: document.getElementById('consoleForm'),
+ consoleInput: document.getElementById('consoleInput'),
+ performanceContent: document.getElementById('performanceContent'),
+ stateContent: document.getElementById('stateContent'),
+ stateFilter: document.getElementById('stateFilter'),
+ stateSection: document.getElementById('stateSection'),
+ stateSummary: document.getElementById('stateSummary'),
+};
+
+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',
+ headers: debuggerApiHeaders(options.body ? { 'Content-Type': 'application/json' } : {}),
+ ...(options.body ? { body: JSON.stringify(options.body) } : {}),
+ });
+ const payload = await response.json();
+ if (!response.ok || payload.error) {
+ throw new Error(payload.error || `Request failed: ${response.status}`);
+ }
+ return payload;
+}
+
+function isDirectMode() {
+ return launchIdentity.mode === 'target-id';
+}
+
+function targetCapabilities(target = state.target) {
+ if (Array.isArray(target?.capabilities)) return new Set(target.capabilities);
+ return launchIdentity.mode === 'inspected-page' ? MANUAL_WEB_CAPABILITIES : new Set();
+}
+
+function targetSupports(capability, target = state.target) {
+ return Boolean(target && targetCapabilities(target).has(capability));
+}
+
+function targetIdentityParameters(target = state.target) {
+ if (!target) throw new Error('No debugger target is selected.');
+ if (isDirectMode()) return { targetId: target.id };
+ if (!target.sessionId || !inspectedUrl || !inspectedTargetNonce) {
+ throw new Error('The selected inspected page does not have a complete target identity.');
+ }
+ return {
+ inspectedUrl,
+ sessionId: target.sessionId,
+ targetNonce: inspectedTargetNonce,
+ };
+}
+
+function targetIdentityKey(target = state.target) {
+ const identity = targetIdentityParameters(target);
+ return Object.keys(identity)
+ .sort()
+ .map(key => `${key}:${identity[key]}`)
+ .join('|');
+}
+
+function targetOperationalKey(target) {
+ return `${targetIdentityKey(target)}|capabilities:${JSON.stringify(Array.from(targetCapabilities(target)).sort())}`;
+}
+
+function targetEventMatches(target, payload) {
+ if (!target || payload.targetId !== target.id) return false;
+ return isDirectMode() || payload.sessionId === target.sessionId;
+}
+
+function boundedRegistryLabel(value, fallback) {
+ if (typeof value !== 'string') return fallback;
+ const normalized = value
+ .slice(0, MAX_REGISTRY_LABEL_CHARACTERS * 2)
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
+ .trim();
+ if (!normalized) return fallback;
+ const characters = Array.from(normalized);
+ return characters.length <= MAX_REGISTRY_LABEL_CHARACTERS
+ ? normalized
+ : `${characters.slice(0, MAX_REGISTRY_LABEL_CHARACTERS - 1).join('')}…`;
+}
+
+function parseRegistryTarget(value) {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
+ const id = value.id;
+ if (
+ typeof id !== 'string' ||
+ id.length === 0 ||
+ id.length > MAX_REGISTRY_ID_CHARACTERS ||
+ /[\u0000-\u001f\u007f]/.test(id) ||
+ typeof value.attachable !== 'boolean' ||
+ !['inspected-page', 'target-id'].includes(value.identityMode) ||
+ typeof value.transport !== 'string' ||
+ value.transport.length === 0 ||
+ value.transport.length > 64 ||
+ !Array.isArray(value.capabilities) ||
+ value.capabilities.length > MAX_REGISTRY_CAPABILITIES
+ ) {
+ return null;
+ }
+
+ const capabilities = [];
+ const seenCapabilities = new Set();
+ for (const capability of value.capabilities) {
+ if (
+ typeof capability !== 'string' ||
+ capability.length === 0 ||
+ capability.length > 64 ||
+ /[\u0000-\u001f\u007f]/.test(capability) ||
+ seenCapabilities.has(capability)
+ ) {
+ return null;
+ }
+ seenCapabilities.add(capability);
+ capabilities.push(capability);
+ }
+
+ if (value.state !== undefined && !['attached', 'available', 'waiting'].includes(value.state)) return null;
+ if (value.platform !== undefined && (typeof value.platform !== 'string' || value.platform.length > 32)) return null;
+ if (value.port !== undefined && (!Number.isSafeInteger(value.port) || value.port <= 0 || value.port > 65_535)) {
+ return null;
+ }
+ const name = boundedRegistryLabel(value.name, '');
+ return {
+ attachable: value.attachable,
+ capabilities,
+ id,
+ identityMode: value.identityMode,
+ name: name || boundedRegistryLabel(value.applicationId, 'Valdi target'),
+ platform: boundedRegistryLabel(value.platform, 'unknown'),
+ ...(value.port === undefined ? {} : { port: value.port }),
+ state: value.state || 'available',
+ transport: value.transport,
+ };
+}
+
+function parseTargetRegistry(payload) {
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload) || !Array.isArray(payload.targets)) {
+ throw new Error('The debugger target registry returned an invalid response.');
+ }
+ if (payload.targets.length > MAX_REGISTRY_ENTRIES) {
+ throw new Error(`The debugger target registry exceeded ${MAX_REGISTRY_ENTRIES} entries.`);
+ }
+ const targets = [];
+ const ids = new Set();
+ for (const value of payload.targets) {
+ const target = parseRegistryTarget(value);
+ if (!target) throw new Error('The debugger target registry contains a malformed entry.');
+ if (ids.has(target.id)) throw new Error(`The debugger target registry contains duplicate target IDs.`);
+ ids.add(target.id);
+ targets.push(target);
+ }
+ return targets;
+}
+
+function isSelectableDirectTarget(target) {
+ const capabilities = targetCapabilities(target);
+ return Boolean(
+ target.attachable &&
+ target.state !== 'waiting' &&
+ target.identityMode === 'target-id' &&
+ target.transport === 'valdi-daemon' &&
+ capabilities.has('components') &&
+ capabilities.has('snapshot'),
+ );
+}
+
+function directTargetUnavailableReason(target) {
+ if (target.identityMode === 'inspected-page') return 'Open from the inspected page';
+ if (!target.attachable || target.state === 'waiting') return 'Waiting for application';
+ if (target.transport !== 'valdi-daemon') return 'Unsupported transport';
+ if (!targetSupports('components', target) || !targetSupports('snapshot', target)) {
+ return 'Component snapshots unavailable';
+ }
+ return 'Unavailable';
+}
+
+function directTargetLabel(target) {
+ const metadata = [target.platform, target.port === undefined ? null : `:${target.port}`].filter(Boolean).join(' ');
+ const reason = isSelectableDirectTarget(target) ? '' : ` — ${directTargetUnavailableReason(target)}`;
+ const label = `${target.name}${metadata ? ` (${metadata})` : ''}${reason}`;
+ const characters = Array.from(label);
+ return characters.length <= MAX_REGISTRY_LABEL_TOTAL_CHARACTERS
+ ? label
+ : `${characters.slice(0, MAX_REGISTRY_LABEL_TOTAL_CHARACTERS - 1).join('')}…`;
+}
+
+function appendTargetOption(value, label, options = {}) {
+ const option = document.createElement('option');
+ option.value = value;
+ option.textContent = label;
+ option.disabled = Boolean(options.disabled);
+ option.selected = Boolean(options.selected);
+ elements.targetSelect.append(option);
+}
+
+function setTargetPickerStatus(message) {
+ elements.targetPickerStatus.textContent = message || '';
+}
+
+function renderTargetPicker() {
+ const direct = isDirectMode();
+ elements.targetSelect.hidden = !direct;
+ elements.targetSelectLabel.hidden = !direct;
+ elements.targetName.hidden = direct;
+ if (!direct) return;
+
+ elements.targetSelect.replaceChildren();
+ const selectedId = state.target?.id || null;
+ const unavailableTarget = state.unavailableTargetId
+ ? state.registryTargets.find(target => target.id === state.unavailableTargetId)
+ : null;
+ appendTargetOption('', state.registryPending ? 'Loading debugger targets…' : 'Choose a debugger target', {
+ disabled: false,
+ selected: selectedId === null,
+ });
+ for (const target of state.registryTargets) {
+ appendTargetOption(target.id, directTargetLabel(target), {
+ disabled: !isSelectableDirectTarget(target),
+ selected: target.id === selectedId,
+ });
+ }
+ if (state.unavailableTargetId && !unavailableTarget) {
+ appendTargetOption(state.unavailableTargetId, 'Requested target is unavailable', { disabled: true });
+ }
+ elements.targetSelect.value = selectedId || '';
+ if (state.targetSwitchMessage) {
+ setTargetPickerStatus(state.targetSwitchMessage);
+ } else if (state.registryError) {
+ setTargetPickerStatus(state.registryError);
+ } else if (state.error) {
+ setTargetPickerStatus(state.error);
+ } else if (state.target) {
+ setTargetPickerStatus(`Connected to ${state.target.name}.`);
+ } else if (state.unavailableTargetId) {
+ setTargetPickerStatus(
+ unavailableTarget && isSelectableDirectTarget(unavailableTarget)
+ ? 'The requested target is available. Select it to connect.'
+ : unavailableTarget
+ ? directTargetUnavailableReason(unavailableTarget)
+ : 'The requested target is unavailable. Choose another target.',
+ );
+ } else {
+ setTargetPickerStatus('Choose a target. Targets are never selected automatically.');
+ }
+}
+
+function stopPerformanceOnPageHide() {
+ const identity = state.performance.ownerIdentity;
+ if (!identity) return;
+ const url = new URL('/api/devtools/performance/trace/stop', window.location.origin);
+ for (const [key, value] of Object.entries(identity)) url.searchParams.set(key, String(value));
+ void fetch(url, {
+ body: '{}',
+ headers: debuggerApiHeaders({ 'Content-Type': 'application/json' }),
+ keepalive: true,
+ method: 'POST',
+ }).catch(error => console.warn('Unable to stop the web preview performance trace while closing DevTools.', error));
+}
+
+function formatNumber(value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return '—';
+ return Number.isInteger(numeric)
+ ? numeric.toLocaleString()
+ : numeric.toLocaleString(undefined, { maximumFractionDigits: 1 });
+}
+
+function formatBytes(value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 0) return '—';
+ if (numeric < 1024) return `${formatNumber(numeric)} B`;
+ if (numeric < 1024 * 1024) return `${formatNumber(numeric / 1024)} KiB`;
+ return `${formatNumber(numeric / (1024 * 1024))} MiB`;
+}
+
+function formatDuration(value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 0) return '—';
+ if (numeric < 1) return `${formatNumber(numeric * 1000)} µs`;
+ if (numeric < 1000) return `${formatNumber(numeric)} ms`;
+ return `${formatNumber(numeric / 1000)} s`;
+}
+
+function formatUptime(value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 0) return '—';
+ if (numeric < 60_000) return `${formatNumber(numeric / 1000)} s`;
+ return `${formatNumber(numeric / 60_000)} min`;
+}
+
+function performanceIdentity(target = state.target) {
+ if (!targetSupports('performance', target)) throw new Error('The selected target does not support Performance.');
+ return targetIdentityParameters(target);
+}
+
+function samePerformanceIdentity(left, right) {
+ if (!left || !right) return false;
+ if (left.targetId !== undefined || right.targetId !== undefined) {
+ return left.targetId !== undefined && left.targetId === right.targetId;
+ }
+ return (
+ left.sessionId === right.sessionId &&
+ left.inspectedUrl === right.inspectedUrl &&
+ left.targetNonce === right.targetNonce
+ );
+}
+
+function performanceIdentityIsCurrent(identity) {
+ // This only rejects stale client-side responses. The server validates the
+ // complete session, inspected URL, and per-tab nonce on every request.
+ try {
+ return samePerformanceIdentity(identity, performanceIdentity());
+ } catch {
+ return false;
+ }
+}
+
+function performancePollingInputIsFocused() {
+ return ['performanceDurationInput', 'performanceTraceFilter'].includes(document.activeElement?.id);
+}
+
+function preparePerformanceForTargetChange() {
+ const perf = state.performance;
+ perf.requestGeneration++;
+ perf.operationGeneration++;
+ perf.snapshotPending = false;
+ perf.pending = false;
+ perf.data = null;
+ perf.lastTrace = null;
+ perf.navigationExpanded = false;
+ perf.rendererTracingEnabled = false;
+ perf.samples = [];
+ perf.traceScope = 'valdi';
+ perf.traceSearch = '';
+ if (perf.traceActive || perf.ownerIdentity) {
+ perf.error = 'The previous web preview still owns a performance recording. Stop and retrieve it before switching.';
+ return;
+ }
+ perf.error = null;
+}
+
+function performanceBlocksTargetSwitch() {
+ const perf = state.performance;
+ return Boolean(perf.pending || perf.traceActive || perf.ownerIdentity);
+}
+
+function sectionIsAvailable(section) {
+ if (section === 'elements') return true;
+ if (section === 'state') return targetSupports('components') && targetSupports('snapshot');
+ if (section === 'console') return targetSupports('console');
+ if (section === 'performance') return targetSupports('performance') || Boolean(state.performance.ownerIdentity);
+ return false;
+}
+
+function updateCapabilityUi() {
+ for (const tab of elements.mainTabs) {
+ const available = sectionIsAvailable(tab.dataset.section);
+ tab.disabled = !available;
+ tab.setAttribute('aria-disabled', String(!available));
+ }
+ elements.consoleInput.disabled = !targetSupports('console');
+ elements.clearConsoleButton.disabled = !targetSupports('console');
+ elements.refreshButton.disabled = !state.target;
+ if (!sectionIsAvailable(state.activeSection)) setActiveSection('elements');
+}
+
+function resetHighlightForTargetChange() {
+ state.highlightIntentGeneration++;
+ if (state.highlightTimer) window.clearTimeout(state.highlightTimer);
+ state.highlightTimer = null;
+ state.hoveredNodeId = null;
+ state.hoveredSnapshotGeneration = 0;
+ state.highlightMayBeActive = false;
+}
+
+function enqueueExactHighlightClear(target) {
+ if (!target || !targetSupports('highlight', target) || !state.highlightMayBeActive) return;
+ const identity = targetIdentityParameters(target);
+ const request = state.highlightRequestTail.then(() =>
+ requestJson('/api/devtools/highlight', {}, { body: identity }).catch(error => {
+ console.warn('Unable to clear the previous Valdi target highlight.', error);
+ }),
+ );
+ state.highlightRequestTail = request.catch(error => {
+ console.warn('Unable to order the previous Valdi target highlight clear.', error);
+ });
+}
+
+function resetConsoleForTargetChange() {
+ clearConsole();
+ state.consoleHistory = [];
+ state.consoleHistoryIndex = 0;
+ elements.consoleInput.value = '';
+}
+
+function resetComponentPropertyEditForTargetChange() {
+ state.componentPropertyEdit.operationGeneration++;
+ state.componentPropertyEdit.pending = false;
+ state.componentPropertyEdit.focused = false;
+ state.componentPropertyEdit.error = null;
+}
+
+function resetRuntimeStateForTargetChange() {
+ const runtimeState = state.runtimeState;
+ runtimeState.inspectGeneration++;
+ runtimeState.inspectorNodeId = null;
+ runtimeState.search = '';
+ runtimeState.expandedComponents.clear();
+ runtimeState.expandedInspectorValues.clear();
+ runtimeState.expandedMainValues.clear();
+ if (elements.stateFilter) elements.stateFilter.value = '';
+ if (elements.stateSummary) elements.stateSummary.textContent = '';
+ if (elements.stateContent) elements.stateContent.innerHTML = '';
+}
+
+function resetRuntimeStateForSelectionChange(selectedNodeId) {
+ if (state.runtimeState.inspectorNodeId === selectedNodeId) return;
+ state.runtimeState.inspectGeneration++;
+ state.runtimeState.inspectorNodeId = selectedNodeId;
+ state.runtimeState.expandedInspectorValues.clear();
+}
+
+function clearTargetPresentation(message) {
+ state.snapshotRequestGeneration++;
+ state.refreshPending = false;
+ state.snapshotRequestCompletion = null;
+ state.snapshot = null;
+ state.snapshotGeneration++;
+ state.selectedNodeId = null;
+ state.remoteSelectedNodeId = null;
+ state.expandedNodeIds.clear();
+ resetHighlightForTargetChange();
+ resetConsoleForTargetChange();
+ resetComponentPropertyEditForTargetChange();
+ resetRuntimeStateForTargetChange();
+ preparePerformanceForTargetChange();
+ elements.treeEmpty.textContent = message;
+ render();
+}
+
+function applyDirectTargetSelection(nextTarget, options = {}) {
+ const currentId = state.target?.id || null;
+ const nextId = nextTarget?.id || null;
+ if (currentId !== null && currentId === nextId && !options.force) {
+ state.targetSwitchMessage = null;
+ renderTargetPicker();
+ return true;
+ }
+ if (!options.force && performanceBlocksTargetSwitch()) {
+ state.targetSwitchMessage = 'Stop or finish the current Performance operation before switching debugger targets.';
+ renderTargetPicker();
+ return false;
+ }
+
+ stopConsoleStream();
+ enqueueExactHighlightClear(state.target);
+ state.targetGeneration++;
+ clearTargetPresentation(nextTarget ? 'Loading the selected target…' : 'Choose a debugger target to inspect.');
+ state.target = nextTarget;
+ state.unavailableTargetId = options.unavailableTargetId || null;
+ state.targetSwitchMessage = options.message || null;
+ state.error = nextTarget
+ ? null
+ : options.unavailableTargetId
+ ? options.message || 'The selected target is unavailable.'
+ : null;
+ if (nextTarget) {
+ elements.targetMetadata.textContent = [
+ nextTarget.platform,
+ nextTarget.port === undefined ? null : `:${nextTarget.port}`,
+ ]
+ .filter(Boolean)
+ .join(' · ');
+ setConnected(true);
+ } else {
+ elements.targetMetadata.textContent = '';
+ setConnected(false);
+ }
+ updateCapabilityUi();
+ if (state.activeSection === 'performance') renderPerformance();
+ renderTargetPicker();
+ if (nextTarget) {
+ startConsoleStream();
+ void refreshSnapshot();
+ }
+ return true;
+}
+
+function nodeAttributes(node) {
+ return valdiDebuggerTreeModel.attributes(node);
+}
+
+function nodeId(node) {
+ return valdiDebuggerTreeModel.id(node);
+}
+
+function inspectedNodeId(node) {
+ if (!node) return null;
+ if (node.component) {
+ return node.component.elementId === undefined ? null : String(node.component.elementId);
+ }
+ return nodeId(node);
+}
+
+function inspectedNode(node) {
+ const id = inspectedNodeId(node);
+ return id === null ? node : findNode(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) {
+ if (isDirectMode()) setTargetPickerStatus(message);
+ else 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 refreshTargetRegistry() {
+ if (!isDirectMode() || state.registryPending) return;
+ state.registryPending = true;
+ const requestGeneration = ++state.registryRequestGeneration;
+ renderTargetPicker();
+ try {
+ const payload = await requestJson('/api/devtools/targets', {}, {});
+ if (requestGeneration !== state.registryRequestGeneration) return;
+ const targets = parseTargetRegistry(payload);
+ state.registryTargets = targets;
+ state.registryGeneration++;
+ state.registryError = null;
+ if (!state.target) state.error = null;
+
+ if (state.initialTargetResolutionPending) {
+ state.initialTargetResolutionPending = false;
+ const requestedId = launchIdentity.requestedTargetId;
+ const requestedTarget = targets.find(target => target.id === requestedId);
+ if (requestedTarget && isSelectableDirectTarget(requestedTarget)) {
+ state.unavailableTargetId = null;
+ applyDirectTargetSelection(requestedTarget, { force: true });
+ } else {
+ state.unavailableTargetId = requestedId;
+ state.error = 'The requested target is unavailable.';
+ updateCapabilityUi();
+ setConnected(false);
+ }
+ } else if (state.target) {
+ const refreshedTarget = targets.find(target => target.id === state.target.id);
+ if (!refreshedTarget || !isSelectableDirectTarget(refreshedTarget)) {
+ const unavailableTargetId = state.target.id;
+ applyDirectTargetSelection(null, {
+ force: true,
+ message: 'The selected target is no longer available. Choose another target.',
+ unavailableTargetId,
+ });
+ } else {
+ const previousTarget = state.target;
+ const targetChanged = targetOperationalKey(previousTarget) !== targetOperationalKey(refreshedTarget);
+ if (targetChanged) {
+ stopConsoleStream();
+ enqueueExactHighlightClear(previousTarget);
+ state.targetGeneration++;
+ clearTargetPresentation('Refreshing the selected target…');
+ }
+ state.target = refreshedTarget;
+ elements.targetMetadata.textContent = [
+ refreshedTarget.platform,
+ refreshedTarget.port === undefined ? null : `:${refreshedTarget.port}`,
+ ]
+ .filter(Boolean)
+ .join(' · ');
+ updateCapabilityUi();
+ if (state.activeSection === 'performance') renderPerformance();
+ if (targetChanged) {
+ startConsoleStream();
+ void refreshSnapshot();
+ }
+ }
+ }
+ if (!state.target) setConnected(false);
+ renderTargetPicker();
+ } catch (error) {
+ if (requestGeneration !== state.registryRequestGeneration) return;
+ state.registryError = error instanceof Error ? error.message : String(error);
+ if (!state.target) {
+ state.error = state.registryError;
+ setConnected(false);
+ }
+ renderTargetPicker();
+ } finally {
+ if (requestGeneration === state.registryRequestGeneration) {
+ state.registryPending = false;
+ renderTargetPicker();
+ }
+ }
+}
+
+async function connectToInspectedPage() {
+ const connectionGeneration = ++state.connectionRequestGeneration;
+
+ try {
+ const payload = await requestJson('/api/devtools/target', { inspectedUrl, targetNonce: inspectedTargetNonce }, {});
+ if (connectionGeneration !== state.connectionRequestGeneration) return;
+ const previousTargetKey = state.target
+ ? `${state.target.id}:${state.target.sessionId}:${inspectedTargetNonce}`
+ : null;
+ const nextTargetKey = `${payload.target.id}:${payload.target.sessionId}:${inspectedTargetNonce}`;
+ if (previousTargetKey !== null && previousTargetKey !== nextTargetKey) {
+ stopConsoleStream();
+ enqueueExactHighlightClear(state.target);
+ resetConsoleForTargetChange();
+ resetComponentPropertyEditForTargetChange();
+ resetRuntimeStateForTargetChange();
+ preparePerformanceForTargetChange();
+ state.snapshotRequestGeneration++;
+ state.refreshPending = false;
+ state.snapshotRequestCompletion = null;
+ state.snapshot = null;
+ state.snapshotGeneration++;
+ state.selectedNodeId = null;
+ state.remoteSelectedNodeId = null;
+ state.expandedNodeIds.clear();
+ resetHighlightForTargetChange();
+ render();
+ }
+ if (previousTargetKey !== nextTargetKey) state.targetGeneration++;
+ state.target = payload.target;
+ if (previousTargetKey !== null && previousTargetKey !== nextTargetKey) render();
+ elements.targetName.textContent = state.target.name || 'Valdi application';
+ elements.targetName.title = state.target.applicationUrl || inspectedUrl;
+ elements.targetMetadata.textContent = `Chromium · :${state.target.debuggingPort}`;
+ renderTargetPicker();
+ updateCapabilityUi();
+ if (state.activeSection === 'performance') renderPerformance();
+ setConnected(true);
+ if (previousTargetKey !== nextTargetKey) {
+ addConsoleEntry('info', `Connected to ${state.target.applicationUrl}`);
+ }
+ startConsoleStream();
+ await refreshSnapshot();
+ startRefreshTimer();
+ } catch (error) {
+ if (connectionGeneration !== state.connectionRequestGeneration) return;
+ reportError(error);
+ window.setTimeout(connectToInspectedPage, 1500);
+ }
+}
+
+async function connectToInspectedApplication() {
+ renderTargetPicker();
+ updateCapabilityUi();
+ if (launchIdentity.error) {
+ state.error = launchIdentity.error;
+ setConnected(false, state.error);
+ elements.treeEmpty.textContent = state.error;
+ return;
+ }
+ if (isDirectMode()) {
+ await refreshTargetRegistry();
+ startRefreshTimer();
+ return;
+ }
+ await connectToInspectedPage();
+}
+
+async function refreshSnapshot() {
+ await refreshSnapshotInternal(null);
+}
+
+function runtimeStatePresentationHasFocus() {
+ const activeElement = document.activeElement;
+ if (!activeElement) return false;
+ if (elements.stateSection?.contains(activeElement)) return true;
+ return state.activeDetail === 'state' && elements.inspector?.contains(activeElement);
+}
+
+async function refreshSnapshotInternal(componentPropertyEditOperationGeneration) {
+ const componentPropertyEditOwnsRefresh = () =>
+ componentPropertyEditOperationGeneration !== null &&
+ state.componentPropertyEdit.operationGeneration === componentPropertyEditOperationGeneration &&
+ state.componentPropertyEdit.pending;
+ const refreshIsAllowed = () =>
+ state.target &&
+ targetSupports('components') &&
+ targetSupports('snapshot') &&
+ ((!state.componentPropertyEdit.focused && !state.componentPropertyEdit.pending) ||
+ componentPropertyEditOwnsRefresh()) &&
+ !runtimeStatePresentationHasFocus();
+ if (!refreshIsAllowed()) {
+ return runtimeStatePresentationHasFocus() ? SNAPSHOT_REFRESH_RUNTIME_STATE_DEFERRED : SNAPSHOT_REFRESH_SKIPPED;
+ }
+ if (state.refreshPending) {
+ const activeRequestCompletion = state.snapshotRequestCompletion;
+ if (!componentPropertyEditOwnsRefresh() || activeRequestCompletion === null) {
+ return SNAPSHOT_REFRESH_SKIPPED;
+ }
+ await activeRequestCompletion;
+ if (!refreshIsAllowed() || state.refreshPending) {
+ return runtimeStatePresentationHasFocus() ? SNAPSHOT_REFRESH_RUNTIME_STATE_DEFERRED : SNAPSHOT_REFRESH_SKIPPED;
+ }
+ }
+ state.refreshPending = true;
+ let resolveRequestCompletion;
+ const requestCompletion = new Promise(resolve => {
+ resolveRequestCompletion = resolve;
+ });
+ state.snapshotRequestCompletion = requestCompletion;
+ const requestTarget = state.target;
+ const requestTargetGeneration = state.targetGeneration;
+ const requestGeneration = ++state.snapshotRequestGeneration;
+ const requestIsCurrent = () =>
+ state.targetGeneration === requestTargetGeneration &&
+ state.target?.id === requestTarget.id &&
+ state.snapshotRequestGeneration === requestGeneration;
+ try {
+ const snapshot = await requestJson('/api/devtools/snapshot', targetIdentityParameters(requestTarget), {});
+ if (!requestIsCurrent()) return SNAPSHOT_REFRESH_SKIPPED;
+ if (runtimeStatePresentationHasFocus()) return SNAPSHOT_REFRESH_RUNTIME_STATE_DEFERRED;
+ if (
+ (state.componentPropertyEdit.focused || state.componentPropertyEdit.pending) &&
+ !componentPropertyEditOwnsRefresh()
+ ) {
+ return SNAPSHOT_REFRESH_SKIPPED;
+ }
+ if (
+ snapshot.target?.id === requestTarget.id &&
+ Array.isArray(snapshot.target.capabilities) &&
+ snapshot.target.capabilities.length <= MAX_REGISTRY_CAPABILITIES &&
+ snapshot.target.capabilities.every(capability => typeof capability === 'string' && capability.length <= 64)
+ ) {
+ state.target = { ...requestTarget, capabilities: [...snapshot.target.capabilities] };
+ updateCapabilityUi();
+ }
+ snapshot.tree = valdiDebuggerTreeModel.restoreTree(snapshot.tree);
+ const wasEmpty = !state.snapshot?.tree;
+ const previousSelectedNodeId = state.selectedNodeId;
+ const shouldClearHighlight = state.hoveredNodeId !== null || state.highlightMayBeActive;
+ state.snapshot = snapshot;
+ state.runtimeState.inspectGeneration++;
+ state.componentPropertyEdit.error = null;
+ state.snapshotGeneration++;
+ if (state.highlightTimer) window.clearTimeout(state.highlightTimer);
+ state.highlightTimer = null;
+ state.hoveredNodeId = null;
+ state.hoveredSnapshotGeneration = state.snapshotGeneration - 1;
+ if (shouldClearHighlight) queueHighlight(null);
+ state.error = null;
+ setConnected(true);
+ renderTargetPicker();
+
+ 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);
+ }
+ if (state.selectedNodeId !== previousSelectedNodeId) {
+ resetRuntimeStateForSelectionChange(state.selectedNodeId);
+ }
+ render();
+ return SNAPSHOT_REFRESH_APPLIED;
+ } catch (error) {
+ if (requestIsCurrent()) reportError(error);
+ return SNAPSHOT_REFRESH_SKIPPED;
+ } finally {
+ if (state.snapshotRequestCompletion === requestCompletion) {
+ state.snapshotRequestCompletion = null;
+ state.refreshPending = false;
+ }
+ resolveRequestCompletion();
+ }
+}
+
+function startRefreshTimer() {
+ if (state.refreshTimer) window.clearInterval(state.refreshTimer);
+ state.refreshTimer = window.setInterval(() => {
+ if (document.hidden) return;
+ if (isDirectMode()) void refreshTargetRegistry();
+ if (!state.autoRefresh) return;
+ if (state.componentPropertyEdit.focused || state.componentPropertyEdit.pending) return;
+ if (runtimeStatePresentationHasFocus()) return;
+ if (state.activeSection === 'elements' || state.activeSection === 'state') void refreshSnapshot();
+ if (state.activeSection === 'performance') void refreshPerformance({ silent: true });
+ }, 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(`
+
+ ${expanded ? '▾' : '▸'}
+ < ${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 runtimeStateParseError(message) {
+ return new Error(`Runtime state ${message}.`);
+}
+
+function runtimeStateKeywordAt(source, offset, keyword) {
+ return source.startsWith(keyword, offset) && !/[A-Za-z0-9_$]/.test(source[offset + keyword.length] || '');
+}
+
+function readRuntimeStateToken(source, parser) {
+ while (parser.offset < source.length && /[\t\n\r ]/.test(source[parser.offset])) parser.offset++;
+ if (parser.offset >= source.length) return { type: 'end' };
+ parser.tokens++;
+ if (parser.tokens > MAX_RUNTIME_STATE_TOKENS) throw runtimeStateParseError('exceeds the token limit');
+ if (parser.sourceType !== RUNTIME_STATE_SOURCE_WEB) {
+ throw runtimeStateParseError('cannot structurally parse a native snapshot');
+ }
+
+ const character = source[parser.offset];
+ if ('{}[]:,'.includes(character)) {
+ parser.offset++;
+ return { type: 'punctuation', value: character };
+ }
+ if (character === '"') {
+ const start = parser.offset;
+ parser.offset++;
+ let closed = false;
+ while (parser.offset < source.length) {
+ const code = source.charCodeAt(parser.offset);
+ if (code < 0x20) throw runtimeStateParseError('contains an invalid string');
+ if (source[parser.offset] === '"') {
+ parser.offset++;
+ closed = true;
+ break;
+ }
+ if (source[parser.offset] !== '\\') {
+ parser.offset++;
+ continue;
+ }
+ parser.offset++;
+ const escape = source[parser.offset];
+ if ('"\\/bfnrt'.includes(escape)) {
+ parser.offset++;
+ continue;
+ }
+ if (escape !== 'u' || !/^[0-9a-fA-F]{4}$/.test(source.slice(parser.offset + 1, parser.offset + 5))) {
+ throw runtimeStateParseError('contains an invalid string escape');
+ }
+ parser.offset += 5;
+ }
+ if (!closed) throw runtimeStateParseError('contains an unterminated string');
+ let value;
+ try {
+ value = JSON.parse(source.slice(start, parser.offset));
+ } catch (_error) {
+ throw runtimeStateParseError('contains an invalid string');
+ }
+ return { type: 'string', value };
+ }
+ if (runtimeStateKeywordAt(source, parser.offset, 'true')) {
+ parser.offset += 4;
+ return { type: 'value', value: true };
+ }
+ if (runtimeStateKeywordAt(source, parser.offset, 'false')) {
+ parser.offset += 5;
+ return { type: 'value', value: false };
+ }
+ if (runtimeStateKeywordAt(source, parser.offset, 'null')) {
+ parser.offset += 4;
+ return { type: 'value', value: null };
+ }
+
+ const start = parser.offset;
+ if (source[parser.offset] === '-') parser.offset++;
+ if (source[parser.offset] === '0') {
+ parser.offset++;
+ if (/\d/.test(source[parser.offset] || '')) throw runtimeStateParseError('contains an invalid number');
+ } else if (/[1-9]/.test(source[parser.offset] || '')) {
+ while (/\d/.test(source[parser.offset] || '')) parser.offset++;
+ } else {
+ throw runtimeStateParseError('contains an unsupported token');
+ }
+ if (source[parser.offset] === '.') {
+ parser.offset++;
+ if (!/\d/.test(source[parser.offset] || '')) throw runtimeStateParseError('contains an invalid number');
+ while (/\d/.test(source[parser.offset] || '')) parser.offset++;
+ }
+ if (source[parser.offset] === 'e' || source[parser.offset] === 'E') {
+ parser.offset++;
+ if (source[parser.offset] === '+' || source[parser.offset] === '-') parser.offset++;
+ if (!/\d/.test(source[parser.offset] || '')) throw runtimeStateParseError('contains an invalid number');
+ while (/\d/.test(source[parser.offset] || '')) parser.offset++;
+ }
+ const value = Number(source.slice(start, parser.offset));
+ if (!Number.isFinite(value)) throw runtimeStateParseError('contains a non-finite number');
+ return { type: 'value', value };
+}
+
+function parseRuntimeState(source, sourceType) {
+ if (typeof source !== 'string') {
+ return { error: 'Runtime state is not a serialized document.', parsed: false, value: null };
+ }
+ if (source.length === 0 || source.length > MAX_RUNTIME_STATE_CHARACTERS) {
+ return {
+ error: `Runtime state must contain between 1 and ${MAX_RUNTIME_STATE_CHARACTERS} characters.`,
+ parsed: false,
+ value: null,
+ };
+ }
+ if (sourceType !== RUNTIME_STATE_SOURCE_NATIVE && sourceType !== RUNTIME_STATE_SOURCE_WEB) {
+ return { error: 'Runtime state has an unknown snapshot source.', parsed: false, value: null };
+ }
+ if (sourceType === RUNTIME_STATE_SOURCE_NATIVE) {
+ return {
+ error: 'Native runtime state is displayed as escaped raw text.',
+ parsed: false,
+ value: null,
+ };
+ }
+
+ const parser = { offset: 0, sourceType, tokens: 0 };
+ const stack = [];
+ let root;
+ let rootSet = false;
+
+ const setValue = value => {
+ if (stack.length === 0) {
+ if (rootSet) throw runtimeStateParseError('contains trailing data');
+ root = value;
+ rootSet = true;
+ return;
+ }
+ const frame = stack[stack.length - 1];
+ if (frame.type === 'array') {
+ if (frame.stage !== 'initialOrEnd' && frame.stage !== 'value') {
+ throw runtimeStateParseError('contains an unexpected array value');
+ }
+ if (frame.entries >= MAX_RUNTIME_STATE_CONTAINER_ENTRIES) {
+ throw runtimeStateParseError('exceeds the per-container entry limit');
+ }
+ frame.values.push(value);
+ frame.entries++;
+ frame.stage = 'commaOrEnd';
+ return;
+ }
+ if (frame.stage !== 'value' || frame.key === null) {
+ throw runtimeStateParseError('contains an unexpected object value');
+ }
+ if (frame.entries >= MAX_RUNTIME_STATE_CONTAINER_ENTRIES) {
+ throw runtimeStateParseError('exceeds the per-container entry limit');
+ }
+ if (Object.prototype.hasOwnProperty.call(frame.target, frame.key)) {
+ throw runtimeStateParseError('contains a duplicate object key');
+ }
+ Object.defineProperty(frame.target, frame.key, {
+ configurable: true,
+ enumerable: true,
+ value,
+ writable: true,
+ });
+ frame.entries++;
+ frame.key = null;
+ frame.stage = 'commaOrEnd';
+ };
+
+ const beginContainer = type => {
+ if (stack.length >= MAX_RUNTIME_STATE_DEPTH) throw runtimeStateParseError('exceeds the depth limit');
+ const target = type === 'array' ? [] : Object.create(null);
+ setValue(target);
+ stack.push({ entries: 0, key: null, stage: 'initialOrEnd', target, type, values: target });
+ };
+
+ const acceptValueToken = token => {
+ if (token.type === 'value' || token.type === 'string') {
+ setValue(token.value);
+ return true;
+ }
+ if (token.type === 'punctuation' && token.value === '{') {
+ beginContainer('object');
+ return true;
+ }
+ if (token.type === 'punctuation' && token.value === '[') {
+ beginContainer('array');
+ return true;
+ }
+ return false;
+ };
+
+ try {
+ while (true) {
+ const token = readRuntimeStateToken(source, parser);
+ if (token.type === 'end') {
+ if (!rootSet) throw runtimeStateParseError('is empty');
+ if (stack.length > 0) throw runtimeStateParseError('contains an unterminated container');
+ return { error: null, parsed: true, value: root };
+ }
+ if (stack.length === 0) {
+ if (rootSet || !acceptValueToken(token)) throw runtimeStateParseError('contains trailing data');
+ continue;
+ }
+
+ const frame = stack[stack.length - 1];
+ if (frame.type === 'object') {
+ if (frame.stage === 'initialOrEnd' || frame.stage === 'key') {
+ if (token.type === 'punctuation' && token.value === '}' && frame.stage === 'initialOrEnd') {
+ stack.pop();
+ continue;
+ }
+ if (token.type !== 'string') {
+ throw runtimeStateParseError('contains an invalid object key');
+ }
+ if (token.value.length > MAX_RUNTIME_STATE_KEY_CHARACTERS) {
+ throw runtimeStateParseError('contains an overlong object key');
+ }
+ frame.key = token.value;
+ frame.stage = 'colon';
+ continue;
+ }
+ if (frame.stage === 'colon') {
+ if (token.type !== 'punctuation' || token.value !== ':') {
+ throw runtimeStateParseError('is missing an object value separator');
+ }
+ frame.stage = 'value';
+ continue;
+ }
+ if (frame.stage === 'value') {
+ if (!acceptValueToken(token)) throw runtimeStateParseError('contains an invalid object value');
+ continue;
+ }
+ if (token.type === 'punctuation' && token.value === ',') {
+ frame.stage = 'key';
+ continue;
+ }
+ if (token.type === 'punctuation' && token.value === '}') {
+ stack.pop();
+ continue;
+ }
+ throw runtimeStateParseError('contains an invalid object separator');
+ }
+
+ if (frame.stage === 'initialOrEnd') {
+ if (token.type === 'punctuation' && token.value === ']') {
+ stack.pop();
+ continue;
+ }
+ if (!acceptValueToken(token)) throw runtimeStateParseError('contains an invalid array value');
+ continue;
+ }
+ if (frame.stage === 'value') {
+ if (!acceptValueToken(token)) throw runtimeStateParseError('contains an invalid array value');
+ continue;
+ }
+ if (token.type === 'punctuation' && token.value === ',') {
+ frame.stage = 'value';
+ continue;
+ }
+ if (token.type === 'punctuation' && token.value === ']') {
+ stack.pop();
+ continue;
+ }
+ throw runtimeStateParseError('contains an invalid array separator');
+ }
+ } catch (error) {
+ return {
+ error: error instanceof Error ? error.message : 'Runtime state could not be parsed.',
+ parsed: false,
+ value: null,
+ };
+ }
+}
+
+function runtimeStateOwnDataValue(source, propertyName) {
+ if (typeof source !== 'object' || source === null) return null;
+ try {
+ const descriptor = Object.getOwnPropertyDescriptor(source, propertyName);
+ if (descriptor?.enumerable !== true || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
+ return null;
+ }
+ return { value: descriptor.value };
+ } catch (_error) {
+ return null;
+ }
+}
+
+function runtimeStateExactNodeId(value) {
+ if (typeof value === 'string' && value.length > 0) return value;
+ if (Number.isSafeInteger(value) && value >= 0) return String(value);
+ return null;
+}
+
+function runtimeStateSelectableNodeId(node) {
+ if (typeof node !== 'object' || node === null) return null;
+ try {
+ const nodeIdDescriptor = Object.getOwnPropertyDescriptor(node, 'id');
+ if (nodeIdDescriptor !== undefined) {
+ if (nodeIdDescriptor.enumerable !== true || !Object.prototype.hasOwnProperty.call(nodeIdDescriptor, 'value')) {
+ return null;
+ }
+ return runtimeStateExactNodeId(nodeIdDescriptor.value);
+ }
+ } catch (_error) {
+ return null;
+ }
+ const elementValue = runtimeStateOwnDataValue(node, 'element')?.value;
+ return runtimeStateExactNodeId(runtimeStateOwnDataValue(elementValue, 'id')?.value);
+}
+
+function runtimeStateSourceType() {
+ return launchIdentity.mode === 'inspected-page' ? RUNTIME_STATE_SOURCE_WEB : RUNTIME_STATE_SOURCE_NATIVE;
+}
+
+function runtimeStateComponentRecord(node, structuralId) {
+ const componentValue = runtimeStateOwnDataValue(node, 'component')?.value;
+ const stateValue = runtimeStateOwnDataValue(componentValue, 'state')?.value;
+ if (typeof stateValue !== 'string' || stateValue.length === 0 || stateValue.length > MAX_RUNTIME_STATE_CHARACTERS) {
+ return null;
+ }
+ const sourceType = runtimeStateSourceType();
+ const nameValue = runtimeStateOwnDataValue(componentValue, 'name')?.value;
+ const componentKeyValue = runtimeStateOwnDataValue(componentValue, 'key')?.value;
+ const nodeKeyValue = runtimeStateOwnDataValue(node, 'key')?.value;
+ const keyValue = sourceType === RUNTIME_STATE_SOURCE_WEB ? componentKeyValue : nodeKeyValue;
+ const tagValue = runtimeStateOwnDataValue(node, 'tag')?.value;
+ return {
+ key: typeof keyValue === 'string' ? keyValue : '',
+ name:
+ typeof nameValue === 'string' && nameValue.length > 0
+ ? nameValue
+ : typeof tagValue === 'string' && tagValue.length > 0
+ ? tagValue
+ : 'Component',
+ node,
+ selectableNodeId: runtimeStateSelectableNodeId(node),
+ source: stateValue,
+ sourceType,
+ structuralId,
+ };
+}
+
+function parseRuntimeStateRecord(record) {
+ const cached = runtimeStateParseCache.get(record.node);
+ if (cached?.source === record.source && cached.sourceType === record.sourceType) return cached.parsed;
+ const parsed = parseRuntimeState(record.source, record.sourceType);
+ runtimeStateParseCache.set(record.node, {
+ parsed,
+ source: record.source,
+ sourceType: record.sourceType,
+ });
+ return parsed;
+}
+
+function collectRuntimeStateComponents(root) {
+ const exactSelectableNodes = new Map();
+ const records = [];
+ const structuralPaths = new WeakMap();
+ let truncated = false;
+ walk(root, (node, ancestors, _depth, sourceChildIndex) => {
+ let structuralPath;
+ if (ancestors.length === 0) {
+ structuralPath = [];
+ } else {
+ const parentPath = structuralPaths.get(ancestors[ancestors.length - 1]);
+ if (!Array.isArray(parentPath) || !Number.isSafeInteger(sourceChildIndex) || sourceChildIndex < 0) {
+ return false;
+ }
+ structuralPath = [...parentPath, sourceChildIndex];
+ }
+ structuralPaths.set(node, structuralPath);
+ const selectableNodeId = runtimeStateSelectableNodeId(node);
+ if (selectableNodeId !== null) {
+ if (exactSelectableNodes.has(selectableNodeId)) exactSelectableNodes.set(selectableNodeId, null);
+ else exactSelectableNodes.set(selectableNodeId, node);
+ }
+ const record = runtimeStateComponentRecord(node, JSON.stringify(structuralPath));
+ if (record === null) return true;
+ if (records.length >= MAX_RUNTIME_STATE_COMPONENT_ROWS) {
+ truncated = true;
+ return true;
+ }
+ records.push(record);
+ return true;
+ });
+ for (const record of records) {
+ if (record.selectableNodeId !== null && exactSelectableNodes.get(record.selectableNodeId) !== record.node) {
+ record.selectableNodeId = null;
+ }
+ }
+ return { records, truncated };
+}
+
+function findRuntimeStateRecordByStructuralId(structuralId) {
+ return (
+ collectRuntimeStateComponents(state.snapshot?.tree).records.find(record => record.structuralId === structuralId) ||
+ null
+ );
+}
+
+function runtimeStateContainerEntries(value) {
+ if (Array.isArray(value)) return value.map((entry, index) => [String(index), entry]);
+ if (typeof value !== 'object' || value === null) return [];
+ return Object.keys(value).map(key => [key, Object.getOwnPropertyDescriptor(value, key)?.value]);
+}
+
+function runtimeStateDescription(value) {
+ if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`;
+ if (typeof value === 'object' && value !== null) {
+ const count = Object.keys(value).length;
+ return `${count} field${count === 1 ? '' : 's'}`;
+ }
+ return typeof value;
+}
+
+function renderRuntimeStatePrimitive(value) {
+ if (value === null) return 'null ';
+ const type = typeof value;
+ const serialized = type === 'string' ? JSON.stringify(value) : String(value);
+ return `${escapeHtml(serialized)} `;
+}
+
+function runtimeStateValuePath(scope, componentId, segments) {
+ return JSON.stringify([scope, componentId, ...segments]);
+}
+
+function renderRuntimeStateEntries(value, context, segments) {
+ const entries = runtimeStateContainerEntries(value);
+ if (entries.length === 0) return 'This state container is empty.
';
+ const rows = [];
+ for (const [key, entryValue] of entries) {
+ if (context.rows >= MAX_RUNTIME_STATE_VALUE_ROWS) {
+ context.truncated = true;
+ break;
+ }
+ context.rows++;
+ const entrySegments = [...segments, key];
+ const keyLabel = Array.isArray(value) ? `[${key}]` : key;
+ if (typeof entryValue !== 'object' || entryValue === null) {
+ rows.push(
+ `${escapeHtml(keyLabel)} : ${renderRuntimeStatePrimitive(entryValue)}
`,
+ );
+ continue;
+ }
+ const path = runtimeStateValuePath(context.scope, context.componentId, entrySegments);
+ const expanded = context.expandedPaths.has(path);
+ rows.push(`
+
+ › ${escapeHtml(keyLabel)} : ${escapeHtml(runtimeStateDescription(entryValue))}
+ ${expanded ? `${renderRuntimeStateEntries(entryValue, context, entrySegments)}
` : ''}
+
+ `);
+ }
+ if (context.truncated && !context.limitRendered) {
+ context.limitRendered = true;
+ rows.push('Additional state rows were omitted.
');
+ }
+ return rows.join('');
+}
+
+function renderRuntimeStateDocument(record, parsed, context) {
+ if (!parsed.parsed || typeof parsed.value !== 'object' || parsed.value === null) {
+ return `${escapeHtml(parsed.error || 'Runtime state is not an object document.')}
${escapeHtml(record.source)} `;
+ }
+ return renderRuntimeStateEntries(parsed.value, context, []);
+}
+
+function hydrateRuntimeStateInspectButtons(models) {
+ const buttons = elements.stateContent?.querySelectorAll?.('[data-runtime-state-inspect-slot]') || [];
+ for (const button of buttons) {
+ const index = Number(button.dataset.runtimeStateInspectSlot);
+ const model = Number.isSafeInteger(index) && index >= 0 ? models[index] : undefined;
+ if (!model) continue;
+ delete button.dataset.runtimeStateInspectSlot;
+ button.dataset.runtimeStateInspect = '';
+ runtimeStateInspectBindings.set(button, model);
+ }
+}
+
+function renderRuntimeStateSection() {
+ if (!elements.stateContent || !elements.stateSummary) return;
+ const renderGeneration = ++state.runtimeState.inspectGeneration;
+ const snapshotGeneration = state.snapshotGeneration;
+ const collected = collectRuntimeStateComponents(state.snapshot?.tree);
+ const normalizedSearch = state.runtimeState.search.trim().toLowerCase();
+ const records = normalizedSearch
+ ? collected.records.filter(record =>
+ `${record.name} ${record.key} ${record.source}`.toLowerCase().includes(normalizedSearch),
+ )
+ : collected.records;
+ elements.stateSummary.textContent = `${records.length}${normalizedSearch ? ` of ${collected.records.length}` : ''} component${collected.records.length === 1 ? '' : 's'}${collected.truncated ? ' · first 500' : ''}`;
+ if (!state.snapshot?.tree) {
+ elements.stateContent.innerHTML = 'Waiting for the inspected component hierarchy…
';
+ return;
+ }
+ if (records.length === 0) {
+ elements.stateContent.innerHTML = `${normalizedSearch ? 'No component state matches this filter.' : 'No mounted component has published bounded runtime state.'}
`;
+ return;
+ }
+
+ const inspectModels = [];
+ const renderContext = {
+ componentId: '',
+ expandedPaths: state.runtimeState.expandedMainValues,
+ rows: 0,
+ limitRendered: false,
+ scope: 'main',
+ truncated: false,
+ };
+ const markup = records
+ .map(record => {
+ const expanded = state.runtimeState.expandedComponents.has(record.structuralId);
+ const parsed = parseRuntimeStateRecord(record);
+ let inspectButton = '';
+ if (record.selectableNodeId !== null) {
+ const inspectIndex =
+ inspectModels.push({
+ generation: renderGeneration,
+ node: record.node,
+ selectableNodeId: record.selectableNodeId,
+ snapshotGeneration,
+ source: record.source,
+ structuralId: record.structuralId,
+ }) - 1;
+ inspectButton = `Inspect `;
+ }
+ renderContext.componentId = record.structuralId;
+ const description = parsed.parsed ? runtimeStateDescription(parsed.value) : 'bounded raw snapshot';
+ const body = expanded
+ ? `${renderRuntimeStateDocument(record, parsed, renderContext)}
`
+ : '';
+ return `
+
+
+ › ${escapeHtml(record.name)} ${record.key ? `${escapeHtml(record.key)} ` : ''}${escapeHtml(description)}
+ ${body}
+
+ ${inspectButton}
+
+ `;
+ })
+ .join('');
+ elements.stateContent.innerHTML = markup;
+ hydrateRuntimeStateInspectButtons(inspectModels);
+}
+
+function renderSelectedRuntimeState(node) {
+ resetRuntimeStateForSelectionChange(nodeId(node));
+ const record = runtimeStateComponentRecord(node, JSON.stringify(['selected']));
+ if (record === null) {
+ return 'This selected component has no bounded runtime state snapshot.
';
+ }
+ const parsed = parseRuntimeStateRecord(record);
+ const context = {
+ componentId: record.structuralId,
+ expandedPaths: state.runtimeState.expandedInspectorValues,
+ rows: 0,
+ limitRendered: false,
+ scope: 'inspector',
+ truncated: false,
+ };
+ return `${renderRuntimeStateDocument(record, parsed, context)} `;
+}
+
+function inspectRuntimeStateBinding(binding) {
+ if (
+ binding?.generation !== state.runtimeState.inspectGeneration ||
+ binding.snapshotGeneration !== state.snapshotGeneration
+ ) {
+ return false;
+ }
+ const currentRecord = findRuntimeStateRecordByStructuralId(binding.structuralId);
+ if (
+ currentRecord?.node !== binding.node ||
+ currentRecord.selectableNodeId !== binding.selectableNodeId ||
+ currentRecord.source !== binding.source
+ ) {
+ return false;
+ }
+ setActiveSection('elements');
+ selectNode(binding.selectableNodeId);
+ setActiveDetail('state');
+ elements.detailTabs.find(tab => tab.dataset.detail === 'state')?.focus?.({ preventScroll: true });
+ return true;
+}
+
+function restoreRuntimeStateDisclosureFocus(scope, componentId, valuePath) {
+ const container = scope === 'inspector' ? elements.inspector : elements.stateContent;
+ const detailsElements = container?.querySelectorAll?.('details') || [];
+ for (const candidate of detailsElements) {
+ if (
+ (componentId !== null && candidate.dataset.runtimeStateComponentId === componentId) ||
+ (valuePath !== null && candidate.dataset.runtimeStateValuePath === valuePath)
+ ) {
+ candidate.querySelector?.('summary')?.focus?.({ preventScroll: true });
+ return;
+ }
+ }
+}
+
+function updateRuntimeStateDisclosure(details) {
+ if (!details?.dataset) return;
+ const valuePath = details.dataset.runtimeStateValuePath;
+ if (valuePath) {
+ const summary = details.querySelector?.('summary');
+ const restoreFocus = summary !== null && summary !== undefined && summary === document.activeElement;
+ const scope = details.dataset.runtimeStateValueScope === 'inspector' ? 'inspector' : 'main';
+ const expandedPaths =
+ scope === 'inspector' ? state.runtimeState.expandedInspectorValues : state.runtimeState.expandedMainValues;
+ if (details.open === expandedPaths.has(valuePath)) return;
+ if (details.open) expandedPaths.add(valuePath);
+ else expandedPaths.delete(valuePath);
+ if (scope === 'inspector') renderInspector();
+ else renderRuntimeStateSection();
+ if (restoreFocus) restoreRuntimeStateDisclosureFocus(scope, null, valuePath);
+ return;
+ }
+ const structuralId = details.dataset.runtimeStateComponentId;
+ if (!structuralId) return;
+ const summary = details.querySelector?.('summary');
+ const restoreFocus = summary !== null && summary !== undefined && summary === document.activeElement;
+ const expanded = state.runtimeState.expandedComponents.has(structuralId);
+ if (details.open === expanded) return;
+ const record = findRuntimeStateRecordByStructuralId(structuralId);
+ if (details.open && record === null) return;
+ if (details.open) state.runtimeState.expandedComponents.add(structuralId);
+ else state.runtimeState.expandedComponents.delete(structuralId);
+ renderRuntimeStateSection();
+ if (restoreFocus) restoreRuntimeStateDisclosureFocus('main', structuralId, null);
+}
+
+function componentPropertyEditMetadata(node, propertyName, value) {
+ if (
+ launchIdentity.mode !== 'inspected-page' ||
+ state.componentPropertyEdit.error !== null ||
+ !targetSupports('component-properties') ||
+ !targetSupports('component-property-edit') ||
+ !node.component ||
+ typeof node.component.propertyEdits !== 'object' ||
+ node.component.propertyEdits === null ||
+ propertyName.trim().length === 0 ||
+ FORBIDDEN_COMPONENT_PROPERTY_NAMES.has(propertyName) ||
+ !['boolean', 'number', 'string'].includes(typeof value) ||
+ (typeof value === 'number' && (!Number.isFinite(value) || Object.is(value, -0)))
+ ) {
+ return null;
+ }
+ let metadataDescriptor;
+ try {
+ metadataDescriptor = Object.getOwnPropertyDescriptor(node.component.propertyEdits, propertyName);
+ } catch (_error) {
+ return null;
+ }
+ const metadata = metadataDescriptor?.value;
+ if (
+ metadataDescriptor?.enumerable !== true ||
+ metadataDescriptor.get !== undefined ||
+ metadataDescriptor.set !== undefined ||
+ typeof metadata !== 'object' ||
+ metadata === null ||
+ Array.isArray(metadata)
+ ) {
+ return null;
+ }
+ let componentTokenDescriptor;
+ let snapshotRevisionDescriptor;
+ try {
+ componentTokenDescriptor = Object.getOwnPropertyDescriptor(metadata, 'componentToken');
+ snapshotRevisionDescriptor = Object.getOwnPropertyDescriptor(metadata, 'snapshotRevision');
+ } catch (_error) {
+ return null;
+ }
+ const componentToken = componentTokenDescriptor?.value;
+ const snapshotRevision = snapshotRevisionDescriptor?.value;
+ if (
+ componentTokenDescriptor?.enumerable !== true ||
+ componentTokenDescriptor.get !== undefined ||
+ componentTokenDescriptor.set !== undefined ||
+ snapshotRevisionDescriptor?.enumerable !== true ||
+ snapshotRevisionDescriptor.get !== undefined ||
+ snapshotRevisionDescriptor.set !== undefined ||
+ typeof componentToken !== 'string' ||
+ !COMPONENT_PROPERTY_TOKEN_PATTERN.test(componentToken) ||
+ !Number.isSafeInteger(snapshotRevision) ||
+ snapshotRevision <= 0
+ ) {
+ return null;
+ }
+ return { componentToken, snapshotRevision };
+}
+
+function createComponentPropertyEditor(node, propertyName, value, metadata) {
+ const valueType = typeof value;
+ const form = document.createElement('form');
+ form.className = 'component-property-editor';
+ form.dataset.componentPropertyEditor = '';
+ const label = document.createElement('label');
+ label.className = 'component-property-label';
+ const propertyNameLabel = document.createElement('span');
+ propertyNameLabel.className = 'property-name';
+ propertyNameLabel.textContent = propertyName;
+ const separator = document.createElement('span');
+ separator.setAttribute('aria-hidden', 'true');
+ separator.textContent = ':';
+ let editor;
+ if (valueType === 'string') {
+ editor = document.createElement('textarea');
+ editor.className = 'component-property-input component-property-string-input';
+ editor.setAttribute('aria-label', `Edit Valdi prop ${propertyName} as a JSON string literal`);
+ editor.setAttribute('rows', '1');
+ editor.setAttribute('spellcheck', 'false');
+ editor.value = JSON.stringify(value);
+ } else {
+ editor = document.createElement('input');
+ editor.className = 'component-property-input';
+ editor.setAttribute('aria-label', `Edit Valdi prop ${propertyName}`);
+ editor.setAttribute('type', valueType === 'boolean' ? 'checkbox' : 'number');
+ if (valueType === 'boolean') editor.checked = value;
+ else {
+ editor.setAttribute('step', 'any');
+ editor.value = String(value);
+ }
+ }
+ editor.dataset.componentPropertyInput = '';
+ const applyButton = document.createElement('button');
+ applyButton.className = 'component-property-apply';
+ applyButton.setAttribute('aria-label', `Apply Valdi prop ${propertyName}`);
+ applyButton.setAttribute('type', 'submit');
+ applyButton.disabled = state.componentPropertyEdit.pending;
+ applyButton.textContent = 'Apply';
+ label.append(propertyNameLabel);
+ label.append(separator);
+ label.append(editor);
+ form.append(label);
+ form.append(applyButton);
+ componentPropertyEditorBindings.set(form, {
+ componentId: node.id,
+ componentToken: metadata.componentToken,
+ propertyName,
+ snapshotRevision: metadata.snapshotRevision,
+ valueType,
+ });
+ return form;
+}
+
+function componentPropertyRows(node, editorModels) {
+ const entries = Object.entries(node.component?.properties || {}).sort(([first], [second]) =>
+ first.localeCompare(second),
+ );
+ if (!entries.length) return 'No properties available.
';
+ return entries
+ .map(([propertyName, value]) => {
+ const metadata = componentPropertyEditMetadata(node, propertyName, value);
+ if (metadata) {
+ const editorIndex = editorModels.push({ metadata, node, propertyName, value }) - 1;
+ return `
`;
+ }
+ return `${escapeHtml(propertyName)} : ${renderValue(value)}
`;
+ })
+ .join('');
+}
+
+function hydrateComponentPropertyEditors(editorModels) {
+ const slots = elements.inspector.querySelectorAll?.('[data-component-property-editor-slot]') || [];
+ for (const slot of slots) {
+ const editorIndex = Number(slot.dataset.componentPropertyEditorSlot);
+ const model = Number.isSafeInteger(editorIndex) && editorIndex >= 0 ? editorModels[editorIndex] : undefined;
+ if (model) {
+ slot.replaceChildren(createComponentPropertyEditor(model.node, model.propertyName, model.value, model.metadata));
+ }
+ }
+}
+
+async function submitComponentPropertyEdit(componentId, propertyName, componentToken, snapshotRevision, value) {
+ if (!state.target || state.componentPropertyEdit.pending) return;
+ const node = findNode(componentId);
+ let currentValue;
+ try {
+ currentValue = Object.getOwnPropertyDescriptor(node?.component?.properties, propertyName)?.value;
+ } catch (_error) {
+ return;
+ }
+ const metadata = node ? componentPropertyEditMetadata(node, propertyName, currentValue) : null;
+ if (
+ !node?.component ||
+ metadata === null ||
+ metadata.componentToken !== componentToken ||
+ metadata.snapshotRevision !== snapshotRevision
+ ) {
+ return;
+ }
+ const requestTarget = state.target;
+ const targetGeneration = state.targetGeneration;
+ const snapshotGeneration = state.snapshotGeneration;
+ const selectedNodeId = state.selectedNodeId;
+ const operationGeneration = ++state.componentPropertyEdit.operationGeneration;
+ const operationIsCurrent = () => state.componentPropertyEdit.operationGeneration === operationGeneration;
+ const requestIsCurrent = () =>
+ operationIsCurrent() &&
+ state.targetGeneration === targetGeneration &&
+ state.target?.id === requestTarget.id &&
+ state.snapshotGeneration === snapshotGeneration &&
+ state.selectedNodeId === selectedNodeId;
+ state.componentPropertyEdit.focused = false;
+ state.componentPropertyEdit.pending = true;
+ state.componentPropertyEdit.error = null;
+ state.snapshotRequestGeneration++;
+ renderInspector();
+ let updated = false;
+ let refreshed = false;
+ let refreshDeferredForRuntimeState = false;
+ try {
+ const result = await requestJson(
+ '/api/devtools/component-property',
+ {},
+ {
+ body: {
+ ...targetIdentityParameters(requestTarget),
+ componentId,
+ componentToken,
+ propertyName,
+ protocolVersion: COMPONENT_PROPERTY_EDIT_PROTOCOL_VERSION,
+ snapshotRevision,
+ value,
+ },
+ },
+ );
+ if (requestIsCurrent()) {
+ updated = result.updated === true;
+ if (updated) {
+ const refreshOutcome = await refreshSnapshotInternal(operationGeneration);
+ refreshed = refreshOutcome === SNAPSHOT_REFRESH_APPLIED;
+ refreshDeferredForRuntimeState = refreshOutcome === SNAPSHOT_REFRESH_RUNTIME_STATE_DEFERRED;
+ }
+ }
+ } catch (error) {
+ if (requestIsCurrent()) {
+ state.componentPropertyEdit.error = error instanceof Error ? error.message : String(error);
+ }
+ } finally {
+ if (operationIsCurrent()) {
+ state.componentPropertyEdit.pending = false;
+ state.componentPropertyEdit.focused = false;
+ if (updated && !refreshed && !refreshDeferredForRuntimeState && state.componentPropertyEdit.error === null) {
+ state.componentPropertyEdit.error = COMPONENT_PROPERTY_EDIT_ERROR;
+ }
+ if (!runtimeStatePresentationHasFocus()) renderInspector();
+ }
+ }
+}
+
+function readComponentPropertyEditorValue(editor, valueType) {
+ if (valueType === 'boolean') return Boolean(editor.checked);
+ if (valueType === 'number') {
+ const input = editor.value.trim();
+ if (!input) return undefined;
+ const value = Number(input);
+ return Number.isFinite(value) && !Object.is(value, -0) ? value : undefined;
+ }
+ if (valueType !== 'string') return undefined;
+ try {
+ const value = JSON.parse(editor.value);
+ return typeof value === 'string' ? value : undefined;
+ } catch (_error) {
+ return undefined;
+ }
+}
+
+function componentMetadata(node) {
+ if (!node.component) return {};
+ return {
+ ...(node.component.elementId === undefined ? {} : { elementId: node.component.elementId }),
+ key: node.component.key,
+ name: node.component.name,
+ };
+}
+
+function renderComponentProperties(node, editorModels) {
+ if (node.component?.properties === undefined || !targetSupports('component-properties')) return '';
+ const editable = targetSupports('component-property-edit') && state.componentPropertyEdit.error === null;
+ return `
+
+
+ ${state.componentPropertyEdit.error ? `${escapeHtml(state.componentPropertyEdit.error)}
` : ''}
+ ${componentPropertyRows(node, editorModels)}
+
+ `;
+}
+
+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;
+ }
+
+ const renderedNode = inspectedNode(node);
+ const componentPropertyEditorModels = [];
+ const renderMarkup = markup => {
+ elements.inspector.innerHTML = markup;
+ hydrateComponentPropertyEditors(componentPropertyEditorModels);
+ };
+ if (state.activeDetail === 'state') {
+ renderMarkup(renderSelectedRuntimeState(node));
+ return;
+ }
+ const componentProperties = renderComponentProperties(node, componentPropertyEditorModels);
+ if (node.component && renderedNode === node) {
+ renderMarkup(
+ `${propertyRows(componentMetadata(node), { css: false })}${componentProperties}This component does not currently render a backing element.
`,
+ );
+ return;
+ }
+
+ if (state.activeDetail === 'styles') {
+ renderMarkup(`${componentProperties}${renderStyles(renderedNode)}`);
+ } else if (state.activeDetail === 'computed') {
+ renderMarkup(`${componentProperties}${renderComputed(renderedNode)}`);
+ } else {
+ const textContent = renderedNode.element?.dom?.textContent
+ ? valdiDebuggerTreeModel.formatValue(renderedNode.element.dom.textContent, 0)
+ : '';
+ const componentDetails = node.component
+ ? `${propertyRows(componentMetadata(node), { css: false })}`
+ : '';
+ renderMarkup(
+ `${componentDetails}${componentProperties}${propertyRows(renderedNode.element?.dom?.attributes, { css: false })}${textContent ? `${escapeHtml(textContent)} ` : ''}`,
+ );
+ }
+}
+
+function renderBreadcrumbs() {
+ const path = findNodePath(state.selectedNodeId);
+ elements.breadcrumbs.innerHTML = path
+ .map(
+ (node, index) =>
+ `${index ? ' › ' : ''}${escapeHtml(node.tag || 'view')} `,
+ )
+ .join('');
+ elements.nodeSummary.textContent = `${nodeCount()} nodes`;
+}
+
+function render() {
+ renderTree();
+ renderInspector();
+ renderBreadcrumbs();
+ if (state.activeSection === 'state') renderRuntimeStateSection();
+}
+
+function selectNode(id) {
+ const node = findNode(id);
+ if (!node) return;
+ const selectedNodeId = nodeId(node);
+ resetRuntimeStateForSelectionChange(selectedNodeId);
+ state.selectedNodeId = selectedNodeId;
+ 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 enqueueHighlightRequest(
+ intentGeneration,
+ target,
+ targetGeneration,
+ snapshotGeneration,
+ identity,
+ nodeIdValue,
+) {
+ const request = state.highlightRequestTail.then(async () => {
+ if (
+ intentGeneration !== state.highlightIntentGeneration ||
+ state.targetGeneration !== targetGeneration ||
+ state.target?.id !== target.id ||
+ state.snapshotGeneration !== snapshotGeneration
+ ) {
+ return;
+ }
+ try {
+ await requestJson(
+ '/api/devtools/highlight',
+ {},
+ {
+ body: {
+ ...identity,
+ ...(nodeIdValue ? { nodeId: nodeIdValue } : {}),
+ },
+ },
+ );
+ if (
+ nodeIdValue === null &&
+ intentGeneration === state.highlightIntentGeneration &&
+ state.targetGeneration === targetGeneration
+ ) {
+ state.highlightMayBeActive = false;
+ }
+ } catch (error) {
+ console.warn('Unable to update the inspected Valdi highlight.', error);
+ }
+ });
+ state.highlightRequestTail = request.catch(error => {
+ console.warn('Unable to order the inspected Valdi highlight request.', error);
+ });
+}
+
+function queueHighlight(nodeIdValue) {
+ if (
+ !state.target ||
+ !targetSupports('highlight') ||
+ (state.hoveredNodeId === nodeIdValue &&
+ state.hoveredSnapshotGeneration === state.snapshotGeneration &&
+ !(nodeIdValue === null && state.highlightMayBeActive))
+ )
+ return;
+ const target = state.target;
+ const targetGeneration = state.targetGeneration;
+ const identity = targetIdentityParameters(target);
+ const snapshotGeneration = state.snapshotGeneration;
+ const intentGeneration = ++state.highlightIntentGeneration;
+ state.hoveredNodeId = nodeIdValue;
+ state.hoveredSnapshotGeneration = snapshotGeneration;
+ if (state.highlightTimer) window.clearTimeout(state.highlightTimer);
+ state.highlightTimer = window.setTimeout(
+ () => {
+ state.highlightTimer = null;
+ if (
+ state.targetGeneration !== targetGeneration ||
+ state.target?.id !== target.id ||
+ state.snapshotGeneration !== snapshotGeneration
+ )
+ return;
+ if (nodeIdValue !== null) state.highlightMayBeActive = true;
+ enqueueHighlightRequest(intentGeneration, target, targetGeneration, snapshotGeneration, identity, nodeIdValue);
+ },
+ nodeIdValue ? 80 : 20,
+ );
+}
+
+function renderPerformanceMetric(label, value) {
+ return `${escapeHtml(label)}
${escapeHtml(value)}
`;
+}
+
+function recordPerformanceSample(data) {
+ const uptimeMs = Number(data?.uptimeMs);
+ if (!Number.isFinite(uptimeMs) || uptimeMs < 0) return;
+ const samples = state.performance.samples;
+ const previous = samples[samples.length - 1];
+ if (previous?.uptimeMs === uptimeMs) return;
+ if (previous && previous.uptimeMs > uptimeMs) samples.length = 0;
+ const sample = { uptimeMs };
+ for (const [property, value] of [
+ ['heapUsedBytes', data?.memory?.usedBytes],
+ ['layoutDurationMs', data?.mainThread?.layoutDurationMs],
+ ['resourceCount', data?.resourceCount],
+ ['scriptDurationMs', data?.mainThread?.scriptDurationMs],
+ ['taskDurationMs', data?.mainThread?.taskDurationMs],
+ ]) {
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) sample[property] = numeric;
+ }
+ samples.push(sample);
+ if (samples.length > MAX_PERFORMANCE_SAMPLES) {
+ samples.splice(0, samples.length - MAX_PERFORMANCE_SAMPLES);
+ }
+}
+
+function performanceGraphPath(points, minimum, maximum) {
+ if (!points.length) return '';
+ const firstTime = points[0].time;
+ const lastTime = points[points.length - 1].time;
+ const timeSpan = Math.max(lastTime - firstTime, 1);
+ const valueSpan = Math.max(maximum - minimum, 1);
+ return points
+ .map((point, index) => {
+ const x = points.length === 1 ? 100 : ((point.time - firstTime) / timeSpan) * 100;
+ const y = 27 - Math.max(0, Math.min(1, (point.value - minimum) / valueSpan)) * 23;
+ return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`;
+ })
+ .join(' ');
+}
+
+function renderSampledPerformanceMetric(label, kind, property, formattedValue) {
+ const points = state.performance.samples
+ .filter(sample => Number.isFinite(sample[property]))
+ .map(sample => ({ time: sample.uptimeMs, value: sample[property] }));
+ if (!points.length) return renderPerformanceMetric(label, formattedValue);
+ const minimum = Math.min(...points.map(point => point.value));
+ const maximum = Math.max(...points.map(point => point.value));
+ const padding = Math.max((maximum - minimum) * 0.12, maximum * 0.015, 1);
+ const path = performanceGraphPath(points, Math.max(0, minimum - padding), maximum + padding);
+ return `
+
+ ${escapeHtml(label)}
+ ${escapeHtml(formattedValue)}
+
+
+
+
+
+
+ `;
+}
+
+function performanceScopeMatches(trace, scope) {
+ const name = String(trace?.trace || '');
+ if (scope === 'valdi') return name.startsWith('Valdi.');
+ if (scope === 'browser') return name.startsWith('Browser.');
+ return name.startsWith('Valdi.') || name.startsWith('Browser.');
+}
+
+function filteredPerformanceTraces(result) {
+ const search = String(state.performance.traceSearch || '')
+ .trim()
+ .toLowerCase();
+ const traces = Array.isArray(result?.traces) ? result.traces : [];
+ return traces.filter(
+ trace =>
+ performanceScopeMatches(trace, state.performance.traceScope) &&
+ (!search ||
+ String(trace.trace || '')
+ .toLowerCase()
+ .includes(search)),
+ );
+}
+
+function performanceScopeCounts(traces) {
+ const counts = { all: 0, browser: 0, valdi: 0 };
+ for (const trace of traces) {
+ if (!performanceScopeMatches(trace, 'all')) continue;
+ counts.all++;
+ if (performanceScopeMatches(trace, 'valdi')) counts.valdi++;
+ if (performanceScopeMatches(trace, 'browser')) counts.browser++;
+ }
+ return counts;
+}
+
+function performanceLane(trace) {
+ const name = String(trace?.trace || '');
+ if (name.startsWith('Valdi.')) return 'valdi';
+ if (name.startsWith('Browser.Layout.')) return 'layout';
+ if (name.startsWith('Browser.Paint.')) return 'paint';
+ if (name.startsWith('Browser.Frames.')) return 'frames';
+ if (name.startsWith('Browser.GC.')) return 'gc';
+ return 'script';
+}
+
+function renderPerformanceTimeline(result) {
+ const traces = Array.isArray(result?.traces) ? result.traces : [];
+ const counts = performanceScopeCounts(traces);
+ const scopes = [
+ ['valdi', 'Valdi'],
+ ['browser', 'Browser'],
+ ['all', 'All'],
+ ];
+ const controls = `
+
+ `;
+ if (!traces.length) {
+ return `${controls}Record an interaction to inspect browser and Valdi renderer events.
`;
+ }
+ const filtered = filteredPerformanceTraces(result);
+ if (!filtered.length) return `${controls}No captured events match this filter.
`;
+
+ let firstTimestamp = Infinity;
+ let lastTimestamp = -Infinity;
+ for (const trace of filtered) {
+ firstTimestamp = Math.min(firstTimestamp, Number(trace.startMicros));
+ lastTimestamp = Math.max(lastTimestamp, Number(trace.endMicros));
+ }
+ const spanMicros = Math.max(lastTimestamp - firstTimestamp, 1000);
+ const displayed = filtered.slice(0, MAX_PERFORMANCE_TIMELINE_ROWS);
+ const rows = displayed
+ .map(trace => {
+ const startMicros = Number(trace.startMicros);
+ const durationMicros = Math.max(0, Number(trace.endMicros) - startMicros);
+ const offset = Math.max(0, Math.min(100, ((startMicros - firstTimestamp) / spanMicros) * 100));
+ const width = Math.max(0.65, Math.min(100 - offset, (durationMicros / spanMicros) * 100));
+ const duration = trace.type === 1 ? 'instant' : formatDuration(durationMicros / 1000);
+ const name = String(trace.trace || '').replace(/^Browser\./, '');
+ return `
+
+ ${escapeHtml(name)}
+
+ ${escapeHtml(duration)}
+
+ `;
+ })
+ .join('');
+ const truncated =
+ filtered.length > displayed.length
+ ? `Showing ${formatNumber(displayed.length)} of ${formatNumber(filtered.length)} matching events. Export includes every bounded event.
`
+ : '';
+ return `
+ ${controls}
+
+ Valdi
+ JavaScript
+ Layout
+ Paint
+ Frames
+ GC
+
+ ${rows}
+ ${truncated}
+ `;
+}
+
+function renderPerformanceSummary(result) {
+ const grouped = new Map();
+ for (const trace of filteredPerformanceTraces(result)) {
+ const name = String(trace.trace || '');
+ const event = grouped.get(name) || { count: 0, durationMs: 0, name };
+ event.count++;
+ event.durationMs += Math.max(0, Number(trace.endMicros) - Number(trace.startMicros)) / 1000;
+ grouped.set(name, event);
+ }
+ const rows = Array.from(grouped.values())
+ .sort((left, right) => right.durationMs - left.durationMs || right.count - left.count)
+ .slice(0, MAX_PERFORMANCE_SUMMARY_ROWS)
+ .map(
+ event =>
+ `${escapeHtml(event.name)} ${escapeHtml(formatNumber(event.count))} ${escapeHtml(formatDuration(event.durationMs))} `,
+ )
+ .join('');
+ if (!rows) return '';
+ return `
+ Total captured duration by event
+
+ `;
+}
+
+function performanceButton(label, action, options = {}) {
+ return `${escapeHtml(label)} `;
+}
+
+function replacePerformanceContent(html) {
+ const contentScrollTop = elements.performanceContent.scrollTop;
+ const previousTimeline = elements.performanceContent.querySelector?.('.performance-timeline');
+ const timelineScrollLeft = previousTimeline?.scrollLeft ?? 0;
+ const timelineScrollTop = previousTimeline?.scrollTop ?? 0;
+ elements.performanceContent.innerHTML = html;
+ elements.performanceContent.scrollTop = contentScrollTop;
+ const nextTimeline = elements.performanceContent.querySelector?.('.performance-timeline');
+ if (nextTimeline) {
+ nextTimeline.scrollLeft = timelineScrollLeft;
+ nextTimeline.scrollTop = timelineScrollTop;
+ }
+}
+
+function renderPerformance(data = state.performance.data) {
+ const perf = state.performance;
+ if (!data) {
+ const error = perf.error ? `${escapeHtml(perf.error)}
` : '';
+ const ownerRecovery = perf.ownerIdentity
+ ? ``
+ : '';
+ replacePerformanceContent(
+ error || ownerRecovery
+ ? `${error}${ownerRecovery}`
+ : 'Loading web preview performance…
',
+ );
+ return;
+ }
+ perf.data = data;
+ recordPerformanceSample(data);
+ const browserMetrics = perf.lastTrace?.browserMetrics || {};
+ const browserSummary = perf.lastTrace?.browserSummary || {};
+ const metrics = [
+ renderSampledPerformanceMetric('JS heap', 'heap', 'heapUsedBytes', formatBytes(data.memory?.usedBytes)),
+ renderSampledPerformanceMetric(
+ 'Main-thread time (page lifetime)',
+ 'main-thread',
+ 'taskDurationMs',
+ formatDuration(data.mainThread?.taskDurationMs),
+ ),
+ renderSampledPerformanceMetric(
+ 'JavaScript time (page lifetime)',
+ 'script',
+ 'scriptDurationMs',
+ formatDuration(data.mainThread?.scriptDurationMs),
+ ),
+ renderSampledPerformanceMetric(
+ 'Layout time (page lifetime)',
+ 'layout',
+ 'layoutDurationMs',
+ formatDuration(data.mainThread?.layoutDurationMs),
+ ),
+ renderSampledPerformanceMetric('Resources', 'resources', 'resourceCount', formatNumber(data.resourceCount)),
+ renderPerformanceMetric('Page uptime', formatUptime(data.uptimeMs)),
+ ];
+ if (perf.lastTrace) {
+ metrics.push(
+ renderPerformanceMetric('Captured events', formatNumber(perf.lastTrace.traceCount)),
+ renderPerformanceMetric('Long tasks', formatNumber(browserSummary.longTaskCount)),
+ renderPerformanceMetric('Layout passes', formatNumber(browserMetrics.LayoutCount)),
+ );
+ }
+ const hasTraceOwner = Boolean(perf.traceActive || perf.ownerIdentity);
+ const rendererStatus = perf.rendererTracingEnabled
+ ? 'Valdi renderer events enabled '
+ : 'Valdi renderer events disabled ';
+ const rendererNotice = perf.rendererTracingEnabled
+ ? ''
+ : `Browser events can be recorded now. Valdi renderer events require reloading the inspected page; reloading can reset page state. ${performanceButton('Enable renderer events', 'enable-tracing', { disabled: perf.pending || hasTraceOwner })}
`;
+ const traceStatus = `${perf.traceActive ? 'Recording' : perf.ownerIdentity ? 'Result pending' : 'Idle'} `;
+ const traceCaption = perf.lastTrace
+ ? `${escapeHtml(formatNumber(perf.lastTrace.traceCount))} events · ${escapeHtml(formatDuration(perf.lastTrace.elapsedMs))}${perf.lastTrace.droppedTraceEventCount ? ` · ${escapeHtml(formatNumber(perf.lastTrace.droppedTraceEventCount))} dropped` : ''}
`
+ : '';
+ const paints = (Array.isArray(data.paints) ? data.paints : [])
+ .map(paint => `${escapeHtml(paint.name)} ${escapeHtml(formatDuration(paint.startTime))} `)
+ .join('');
+ replacePerformanceContent(`
+ ${metrics.join('')}
+ Main-thread, JavaScript, and layout counters are cumulative for the current page lifetime.
+ ${perf.error ? `${escapeHtml(perf.error)}
` : ''}
+
+
+ Initial page-load milestones
+ DOM ready ${escapeHtml(formatDuration(data.navigation?.domContentLoadedMs))} · Page load ${escapeHtml(formatDuration(data.navigation?.loadMs))} · Transferred ${escapeHtml(formatBytes(data.transferSize))}
+ ${paints ? `` : 'No paint milestones have been reported.
'}
+
+ `);
+}
+
+function buildPerformanceTraceExport(result) {
+ const traces = Array.isArray(result?.traces) ? result.traces : [];
+ const firstTimestamp = traces.reduce(
+ (minimum, trace) => Math.min(minimum, Number(trace.startMicros)),
+ Number(traces[0]?.startMicros || 0),
+ );
+ const threadIds = Array.from(new Set(traces.map(trace => Number(trace.threadId)))).slice(0, 256);
+ const traceEvents = [
+ { args: { name: 'Valdi web preview' }, name: 'process_name', ph: 'M', pid: 1 },
+ ...threadIds.map(threadId => ({
+ args: { name: `Thread ${threadId}` },
+ name: 'thread_name',
+ ph: 'M',
+ pid: 1,
+ tid: threadId,
+ })),
+ ];
+ for (const trace of traces) {
+ const instant = trace.type === 1;
+ const event = {
+ cat: String(trace.trace || '').startsWith('Valdi.') ? 'valdi' : 'browser',
+ name: String(trace.trace || ''),
+ ph: instant ? 'i' : 'X',
+ pid: 1,
+ tid: Number(trace.threadId),
+ ts: Number(trace.startMicros) - firstTimestamp,
+ };
+ if (instant) event.s = 't';
+ else event.dur = Math.max(0, Number(trace.endMicros) - Number(trace.startMicros));
+ traceEvents.push(event);
+ }
+ return {
+ displayTimeUnit: 'ms',
+ metadata: result?.perfettoMetadata || {},
+ traceEvents,
+ };
+}
+
+function downloadPerformanceTrace(result) {
+ const blob = new Blob([JSON.stringify(buildPerformanceTraceExport(result), null, 2)], {
+ type: 'application/json',
+ });
+ const artifactUrl = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = artifactUrl;
+ link.download = `valdi-web-preview-${Date.now()}.trace.json`;
+ link.click();
+ window.setTimeout(() => URL.revokeObjectURL(artifactUrl), 1000);
+}
+
+async function refreshPerformance(options = {}) {
+ if (options.silent && performancePollingInputIsFocused()) return;
+ if (
+ !state.target ||
+ !targetSupports('performance') ||
+ state.performance.pending ||
+ state.performance.snapshotPending
+ ) {
+ return;
+ }
+ const perf = state.performance;
+ const identity = performanceIdentity();
+ const targetGeneration = state.targetGeneration;
+ const requestGeneration = ++perf.requestGeneration;
+ const requestIsCurrent = () =>
+ targetGeneration === state.targetGeneration &&
+ requestGeneration === perf.requestGeneration &&
+ performanceIdentityIsCurrent(identity);
+ perf.snapshotPending = true;
+ if (!options.silent && !perf.data) renderPerformance();
+ try {
+ const [data, status] = await Promise.all([
+ requestJson('/api/devtools/performance/snapshot', identity, {}),
+ requestJson('/api/devtools/performance/trace/status', identity, {}),
+ ]);
+ if (status.completionError) {
+ try {
+ await requestJson('/api/devtools/performance/trace/stop', identity, { body: {} });
+ } catch {
+ // Stop surfaces the retained completion error after clearing it on the server.
+ }
+ if (requestIsCurrent() && (!perf.ownerIdentity || samePerformanceIdentity(perf.ownerIdentity, identity))) {
+ perf.traceActive = false;
+ perf.ownerIdentity = null;
+ }
+ throw new Error(status.completionError);
+ }
+ let completedTrace = null;
+ if (status.completedRecordingAvailable) {
+ completedTrace = await requestJson('/api/devtools/performance/trace/stop', identity, { body: {} });
+ }
+ if (!requestIsCurrent()) return;
+ perf.data = data;
+ if (!perf.ownerIdentity || samePerformanceIdentity(perf.ownerIdentity, identity)) {
+ perf.traceActive = Boolean(status.recording);
+ perf.ownerIdentity = perf.traceActive ? identity : null;
+ }
+ perf.rendererTracingEnabled = Boolean(data.rendererTracingEnabled || status.rendererTracingEnabled);
+ if (completedTrace) {
+ perf.traceActive = false;
+ perf.ownerIdentity = null;
+ perf.lastTrace = completedTrace;
+ }
+ perf.error = null;
+ renderPerformance(data);
+ } catch (error) {
+ if (requestIsCurrent()) {
+ perf.error = error instanceof Error ? error.message : String(error);
+ renderPerformance(perf.data);
+ }
+ } finally {
+ if (requestIsCurrent()) perf.snapshotPending = false;
+ }
+}
+
+function retainStalePerformanceOwnerAfterCleanupFailure(identity, cleanupError, operationGeneration) {
+ const perf = state.performance;
+ if (perf.ownerIdentity) {
+ const ownerDescription = samePerformanceIdentity(perf.ownerIdentity, identity)
+ ? 'the same recording is already retained by a newer operation'
+ : 'a different recording is already owned by a newer operation';
+ console.warn(`Unable to retain a stale Performance recording because ${ownerDescription}.`, cleanupError);
+ return;
+ }
+ if (operationGeneration !== perf.operationGeneration && perf.pending) {
+ console.warn('Unable to retain a stale Performance recording while a newer operation is pending.', cleanupError);
+ return;
+ }
+
+ // Exact cleanup failed. Claim a fresh repair generation only when no newer
+ // operation or owner needs the state, then retain the orphaned exact owner.
+ const repairGeneration =
+ operationGeneration === perf.operationGeneration ? operationGeneration : ++perf.operationGeneration;
+ if (repairGeneration !== perf.operationGeneration) return;
+ perf.traceActive = true;
+ perf.ownerIdentity = identity;
+ perf.error = `The previous web preview still owns a performance recording: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
+ updateCapabilityUi();
+ if (state.activeSection === 'performance') renderPerformance(perf.data);
+}
+
+async function runPerformanceAction(action) {
+ const perf = state.performance;
+ if (action === 'refresh') {
+ await refreshPerformance();
+ return;
+ }
+ if (action === 'trace-export') {
+ if (perf.lastTrace) downloadPerformanceTrace(perf.lastTrace);
+ return;
+ }
+ const stoppingPreviousOwner = action === 'trace-stop' && perf.ownerIdentity;
+ if (perf.pending || (!stoppingPreviousOwner && (!state.target || !targetSupports('performance')))) return;
+ if (['enable-tracing', 'trace-capture', 'trace-start'].includes(action) && (perf.traceActive || perf.ownerIdentity)) {
+ return;
+ }
+ if (
+ action === 'enable-tracing' &&
+ !window.confirm('Enable Valdi renderer events? This reloads the inspected page and can reset page state.')
+ ) {
+ return;
+ }
+
+ const selectedIdentity = state.target && targetSupports('performance') ? performanceIdentity() : null;
+ const targetGeneration = state.targetGeneration;
+ const identity = stoppingPreviousOwner ? perf.ownerIdentity : selectedIdentity;
+ if (!identity) return;
+ perf.requestGeneration++;
+ perf.snapshotPending = false;
+ const operationGeneration = ++perf.operationGeneration;
+ const operationIsCurrent = () => operationGeneration === perf.operationGeneration;
+ const selectedTargetIsCurrent = () =>
+ operationIsCurrent() &&
+ selectedIdentity !== null &&
+ targetGeneration === state.targetGeneration &&
+ performanceIdentityIsCurrent(selectedIdentity);
+ const operationOwnsTrace = () => samePerformanceIdentity(perf.ownerIdentity, identity);
+ let refreshSelectedTarget = false;
+ perf.pending = true;
+ perf.error = null;
+ renderPerformance(perf.data);
+ try {
+ if (action === 'enable-tracing') {
+ await requestJson('/api/devtools/performance/trace/enable', identity, { body: {} });
+ if (!selectedTargetIsCurrent()) return;
+ perf.rendererTracingEnabled = true;
+ addConsoleEntry('info', 'Reloaded the inspected page with Valdi renderer events enabled.');
+ } else {
+ const operation = action.slice('trace-'.length);
+ const durationMs = Math.round(Math.max(0.1, Math.min(15, Number(perf.durationSeconds) || 3)) * 1000);
+ if (operation === 'capture') {
+ perf.traceActive = true;
+ perf.ownerIdentity = identity;
+ renderPerformance(perf.data);
+ }
+ const result = await requestJson(`/api/devtools/performance/trace/${operation}`, identity, {
+ body: operation === 'capture' ? { durationMs } : {},
+ });
+ if (operation === 'start') {
+ if (!selectedTargetIsCurrent()) {
+ if (result.recording) {
+ try {
+ await requestJson('/api/devtools/performance/trace/stop', identity, { body: {} });
+ } catch (cleanupError) {
+ retainStalePerformanceOwnerAfterCleanupFailure(identity, cleanupError, operationGeneration);
+ }
+ }
+ return;
+ }
+ perf.traceActive = Boolean(result.recording);
+ perf.ownerIdentity = perf.traceActive ? identity : null;
+ refreshSelectedTarget = true;
+ } else {
+ if (!operationIsCurrent()) return;
+ const completedOwnedTrace = ['capture', 'stop'].includes(operation) && operationOwnsTrace();
+ const completedPreviousOwner = completedOwnedTrace && !samePerformanceIdentity(identity, selectedIdentity);
+ if (completedOwnedTrace) {
+ perf.traceActive = false;
+ perf.ownerIdentity = null;
+ }
+ if (completedPreviousOwner) {
+ perf.data = null;
+ perf.lastTrace = null;
+ perf.rendererTracingEnabled = false;
+ perf.samples = [];
+ }
+ if (selectedTargetIsCurrent() && !completedPreviousOwner) {
+ perf.traceActive = false;
+ perf.ownerIdentity = null;
+ perf.lastTrace = result;
+ }
+ refreshSelectedTarget = selectedTargetIsCurrent();
+ }
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (action === 'trace-capture' && selectedTargetIsCurrent()) refreshSelectedTarget = true;
+ if (operationIsCurrent() && (selectedTargetIsCurrent() || operationOwnsTrace())) {
+ perf.error = message;
+ if (state.activeSection === 'performance') renderPerformance(perf.data);
+ } else {
+ console.warn('Ignoring a stale web preview performance action error.', error);
+ }
+ } finally {
+ if (operationIsCurrent()) {
+ perf.pending = false;
+ if (state.activeSection === 'performance') renderPerformance(perf.data);
+ updateCapabilityUi();
+ }
+ }
+ if (refreshSelectedTarget && selectedTargetIsCurrent() && state.target) {
+ await refreshPerformance({ silent: true });
+ }
+}
+
+function setActiveSection(section) {
+ const activeSection = sectionIsAvailable(section) ? section : 'elements';
+ state.activeSection = activeSection;
+ for (const tab of elements.mainTabs) {
+ const selected = tab.dataset.section === activeSection;
+ tab.classList.toggle('selected', selected);
+ tab.setAttribute('aria-selected', String(selected));
+ tab.tabIndex = selected ? 0 : -1;
+ }
+ for (const panel of elements.sections) {
+ const selected = panel.dataset.panel === activeSection;
+ panel.classList.toggle('selected', selected);
+ panel.hidden = !selected;
+ }
+ if (activeSection === 'console') elements.consoleInput.focus();
+ if (activeSection === 'elements') void refreshSnapshot();
+ if (activeSection === 'state') {
+ renderRuntimeStateSection();
+ void refreshSnapshot();
+ }
+ if (activeSection === 'performance') {
+ renderPerformance();
+ void refreshPerformance();
+ }
+}
+
+function handleMainTabNavigation(event) {
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
+ const tabs = elements.mainTabs.filter(tab => !tab.disabled);
+ if (!tabs.length) return;
+ event.preventDefault();
+ const currentIndex = Math.max(0, tabs.indexOf(event.currentTarget));
+ const nextIndex =
+ event.key === 'Home'
+ ? 0
+ : event.key === 'End'
+ ? tabs.length - 1
+ : (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
+ const nextTab = tabs[nextIndex];
+ setActiveSection(nextTab.dataset.section);
+ nextTab.focus();
+}
+
+function setActiveDetail(detail) {
+ 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));
+ tab.tabIndex = selected ? 0 : -1;
+ if (selected && tab.id) elements.inspector.setAttribute('aria-labelledby', tab.id);
+ }
+ renderInspector();
+}
+
+function handleDetailTabNavigation(event) {
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
+ const tabs = elements.detailTabs.filter(tab => !tab.disabled);
+ if (!tabs.length) return;
+ event.preventDefault();
+ const currentIndex = Math.max(0, tabs.indexOf(event.currentTarget));
+ const nextIndex =
+ event.key === 'Home'
+ ? 0
+ : event.key === 'End'
+ ? tabs.length - 1
+ : (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
+ const nextTab = tabs[nextIndex];
+ setActiveDetail(nextTab.dataset.detail);
+ nextTab.focus();
+}
+
+function stopConsoleStream() {
+ if (state.consoleStream) state.consoleStream.close();
+ state.consoleStream = null;
+ state.consoleStreamTargetKey = null;
+}
+
+function startConsoleStream() {
+ if (!state.target || !state.autoRefresh || !targetSupports('console')) {
+ stopConsoleStream();
+ return;
+ }
+ const target = state.target;
+ const targetGeneration = state.targetGeneration;
+ const identity = targetIdentityParameters(target);
+ const targetKey = `${targetGeneration}:${targetIdentityKey(target)}`;
+ if (state.consoleStream && state.consoleStreamTargetKey === targetKey) return;
+ stopConsoleStream();
+
+ const url = new URL('/api/devtools/console/stream', window.location.origin);
+ for (const [key, value] of Object.entries(identity)) url.searchParams.set(key, String(value));
+ const stream = new EventSource(debuggerEventSourceUrl(url.toString()));
+ state.consoleStream = stream;
+ state.consoleStreamTargetKey = targetKey;
+
+ stream.addEventListener('console', event => {
+ if (state.consoleStream !== stream || state.targetGeneration !== targetGeneration || state.target?.id !== target.id)
+ return;
+ let entry;
+ try {
+ entry = JSON.parse(event.data);
+ } catch (error) {
+ console.warn('[Valdi DevTools] Ignoring a malformed Chromium console event.', error);
+ return;
+ }
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return;
+ if (!targetEventMatches(target, entry) || typeof entry.message !== 'string') {
+ return;
+ }
+ addConsoleEntry(entry.level, entry.message, entry.timestamp, entry.source);
+ });
+
+ stream.addEventListener('stream-error', event => {
+ if (state.consoleStream !== stream || state.targetGeneration !== targetGeneration || state.target?.id !== target.id)
+ return;
+ try {
+ const payload = JSON.parse(event.data);
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return;
+ if (targetEventMatches(target, payload) && typeof payload.error === 'string') {
+ addConsoleEntry('error', payload.error);
+ }
+ } catch (error) {
+ console.warn('[Valdi DevTools] Ignoring a malformed Chromium console stream error.', error);
+ }
+ });
+
+ stream.addEventListener('stream-warning', event => {
+ if (state.consoleStream !== stream || state.targetGeneration !== targetGeneration || state.target?.id !== target.id)
+ return;
+ try {
+ const payload = JSON.parse(event.data);
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return;
+ if (targetEventMatches(target, payload) && typeof payload.message === 'string') {
+ addConsoleEntry('warn', payload.message);
+ }
+ } catch (error) {
+ console.warn('[Valdi DevTools] Ignoring a malformed Chromium console stream warning.', error);
+ }
+ });
+}
+
+function addConsoleEntry(kind, value, timestamp, source) {
+ const normalizedKind = ['debug', 'error', 'info', 'input', 'log', 'result', 'warn'].includes(kind) ? kind : 'log';
+ const text = String(value);
+ const boundedValue =
+ text.length > MAX_CONSOLE_ENTRY_CHARACTERS ? `${text.slice(0, MAX_CONSOLE_ENTRY_CHARACTERS - 1)}…` : text;
+ const key = timestamp === undefined ? null : `${timestamp}:${normalizedKind}:${String(source ?? '')}:${boundedValue}`;
+ if (key !== null) {
+ if (state.consoleEntryKeys.has(key)) return;
+ state.consoleEntryKeys.add(key);
+ }
+ state.consoleEntries.push({ key, kind: normalizedKind, value: boundedValue });
+ if (state.consoleEntries.length > MAX_CONSOLE_ENTRIES) {
+ const discarded = state.consoleEntries.splice(0, state.consoleEntries.length - MAX_CONSOLE_ENTRIES);
+ for (const entry of discarded) {
+ if (entry.key !== null) state.consoleEntryKeys.delete(entry.key);
+ }
+ }
+ elements.consoleMessages.innerHTML = state.consoleEntries
+ .map(
+ entry =>
+ `${entry.kind === 'input' ? '›' : entry.kind === 'error' ? '×' : entry.kind === 'warn' ? '!' : '‹'} ${escapeHtml(entry.value)} `,
+ )
+ .join('');
+ elements.consoleMessages.scrollTop = elements.consoleMessages.scrollHeight;
+}
+
+function clearConsole() {
+ state.consoleEntries = [];
+ state.consoleEntryKeys.clear();
+ elements.consoleMessages.innerHTML = '';
+}
+
+async function evaluateConsoleExpression(expression) {
+ if (!state.target || !targetSupports('console')) return;
+ const target = state.target;
+ const targetGeneration = state.targetGeneration;
+ const identity = targetIdentityParameters(target);
+ const requestIsCurrent = () => state.targetGeneration === targetGeneration && state.target?.id === target.id;
+ addConsoleEntry('input', expression);
+ try {
+ const result = await requestJson(
+ '/api/devtools/evaluate',
+ {},
+ {
+ body: {
+ expression,
+ ...identity,
+ },
+ },
+ );
+ if (!requestIsCurrent()) return;
+ const serialized = result.type === 'undefined' ? undefined : JSON.stringify(result.value, null, 2);
+ const value = result.type === 'undefined' ? 'undefined' : (serialized ?? String(result.value));
+ addConsoleEntry('result', value);
+ } catch (error) {
+ if (requestIsCurrent()) addConsoleEntry('error', error instanceof Error ? error.message : String(error));
+ }
+}
+
+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));
+ tab.addEventListener('keydown', handleMainTabNavigation);
+ }
+ for (const tab of elements.detailTabs) {
+ tab.addEventListener('click', () => setActiveDetail(tab.dataset.detail));
+ tab.addEventListener('keydown', handleDetailTabNavigation);
+ }
+ elements.targetSelect.addEventListener('change', () => {
+ if (!isDirectMode()) return;
+ const targetId = elements.targetSelect.value;
+ if (!targetId) {
+ applyDirectTargetSelection(null);
+ return;
+ }
+ const target = state.registryTargets.find(candidate => candidate.id === targetId);
+ if (!target || !isSelectableDirectTarget(target)) {
+ state.targetSwitchMessage = target
+ ? directTargetUnavailableReason(target)
+ : 'The requested target is unavailable.';
+ renderTargetPicker();
+ return;
+ }
+ state.unavailableTargetId = null;
+ applyDirectTargetSelection(target);
+ });
+ elements.refreshButton.addEventListener('click', () => {
+ if (isDirectMode()) void refreshTargetRegistry();
+ if (state.activeSection === 'performance') void refreshPerformance();
+ else void refreshSnapshot();
+ });
+ for (const button of document.querySelectorAll('.section-header [data-performance-action]')) {
+ button.addEventListener('click', () => void runPerformanceAction(button.dataset.performanceAction));
+ }
+ elements.performanceContent.addEventListener('click', event => {
+ const scope = event.target.closest('[data-performance-scope]');
+ if (scope) {
+ state.performance.traceScope = scope.dataset.performanceScope;
+ renderPerformance();
+ return;
+ }
+ const button = event.target.closest('[data-performance-action]');
+ if (button && !button.disabled) void runPerformanceAction(button.dataset.performanceAction);
+ });
+ elements.performanceContent.addEventListener('input', event => {
+ if (event.target.id === 'performanceDurationInput') {
+ state.performance.durationSeconds = Math.max(0.1, Math.min(15, Number(event.target.value) || 3));
+ return;
+ }
+ if (event.target.id !== 'performanceTraceFilter') return;
+ const selectionStart = event.target.selectionStart;
+ const selectionEnd = event.target.selectionEnd;
+ state.performance.traceSearch = event.target.value;
+ renderPerformance();
+ const filter = document.getElementById('performanceTraceFilter');
+ filter?.focus();
+ if (selectionStart !== null && selectionEnd !== null) filter?.setSelectionRange(selectionStart, selectionEnd);
+ });
+ elements.performanceContent.addEventListener(
+ 'toggle',
+ event => {
+ if (event.target?.tagName === 'DETAILS' && event.target.classList.contains('performance-navigation')) {
+ state.performance.navigationExpanded = event.target.open;
+ }
+ },
+ true,
+ );
+ elements.autoRefreshToggle.addEventListener('change', () => {
+ state.autoRefresh = elements.autoRefreshToggle.checked;
+ if (state.autoRefresh) {
+ startConsoleStream();
+ } else {
+ stopConsoleStream();
+ }
+ });
+ elements.treeFilter.addEventListener('input', () => {
+ state.search = elements.treeFilter.value;
+ renderTree();
+ });
+ elements.stateFilter.addEventListener('input', () => {
+ state.runtimeState.search = elements.stateFilter.value;
+ renderRuntimeStateSection();
+ });
+ elements.stateContent.addEventListener('click', event => {
+ const button = event.target.closest?.('[data-runtime-state-inspect]');
+ if (!button) return;
+ event.preventDefault();
+ event.stopPropagation();
+ inspectRuntimeStateBinding(runtimeStateInspectBindings.get(button));
+ });
+ elements.stateContent.addEventListener('toggle', event => updateRuntimeStateDisclosure(event.target), true);
+ elements.expandButton.addEventListener('click', () => {
+ expandUsefulNodes(state.snapshot?.tree);
+ if (state.selectedNodeId) revealPath(state.selectedNodeId);
+ 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(inspectedNodeId(findNode(row.dataset.nodeId)));
+ });
+ elements.tree.addEventListener('pointerleave', () => queueHighlight(null));
+ elements.inspector.addEventListener('focusin', event => {
+ if (event.target.closest?.('[data-component-property-editor]')) {
+ state.componentPropertyEdit.focused = true;
+ }
+ });
+ elements.inspector.addEventListener('focusout', () => {
+ window.setTimeout(() => {
+ state.componentPropertyEdit.focused = Boolean(
+ document.activeElement?.closest?.('[data-component-property-editor]'),
+ );
+ }, 0);
+ });
+ elements.inspector.addEventListener('submit', event => {
+ const form = event.target.closest?.('[data-component-property-editor]');
+ if (!form) return;
+ event.preventDefault();
+ const binding = componentPropertyEditorBindings.get(form);
+ const editor = form.querySelector('[data-component-property-input]');
+ const value = editor && binding ? readComponentPropertyEditorValue(editor, binding.valueType) : undefined;
+ if (
+ !binding ||
+ value === undefined ||
+ !Number.isSafeInteger(binding.snapshotRevision) ||
+ binding.snapshotRevision <= 0 ||
+ !COMPONENT_PROPERTY_TOKEN_PATTERN.test(binding.componentToken)
+ ) {
+ state.componentPropertyEdit.error = 'Enter a valid scalar value before applying this property.';
+ state.componentPropertyEdit.focused = false;
+ renderInspector();
+ return;
+ }
+ void submitComponentPropertyEdit(
+ binding.componentId,
+ binding.propertyName,
+ binding.componentToken,
+ binding.snapshotRevision,
+ value,
+ );
+ });
+ elements.inspector.addEventListener('toggle', event => updateRuntimeStateDisclosure(event.target), true);
+ elements.breadcrumbs.addEventListener('click', event => {
+ const button = event.target.closest('[data-breadcrumb-id]');
+ if (button) selectNode(button.dataset.breadcrumbId);
+ });
+ elements.copyNodeButton.addEventListener('click', async () => {
+ const json = selectedNodeProjectionJson();
+ if (json) await navigator.clipboard.writeText(json);
+ });
+ elements.splitHandle.addEventListener('pointerdown', startSplitResize);
+ elements.clearConsoleButton.addEventListener('click', clearConsole);
+ elements.consoleForm.addEventListener('submit', event => {
+ event.preventDefault();
+ const expression = elements.consoleInput.value.trim();
+ if (!expression || !state.target || !targetSupports('console')) return;
+ state.consoleHistory.push(expression);
+ if (state.consoleHistory.length > MAX_CONSOLE_HISTORY_ENTRIES) {
+ state.consoleHistory.splice(0, state.consoleHistory.length - MAX_CONSOLE_HISTORY_ENTRIES);
+ }
+ 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);
+ }
+ });
+ window.addEventListener('pagehide', () => {
+ stopConsoleStream();
+ stopPerformanceOnPageHide();
+ });
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden && isDirectMode()) void refreshTargetRegistry();
+ if (!document.hidden && (state.activeSection === 'elements' || state.activeSection === 'state')) {
+ void refreshSnapshot();
+ }
+ if (!document.hidden && state.activeSection === 'performance') void refreshPerformance({ silent: true });
+ });
+}
+
+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..e8131c77e 100644
--- a/npm_modules/cli/debugger/index.html
+++ b/npm_modules/cli/debugger/index.html
@@ -62,7 +62,15 @@ Daemon Status
Performance
- Hermes CPU profiles
+ Renderer traces, CPU profiles
+
+
+ Data
+ Storage, SQL
+
+
+ Settings
+ Application-published controls
Logs
@@ -153,9 +161,25 @@ Inspector
>
Performance
-
Capture Hermes CPU profiles for a connected Valdi JavaScript context.
+
Capture renderer activity and Hermes CPU profiles for the attached Valdi target.
+
+
+
+
@@ -207,11 +264,14 @@
Performance
+
+
+
diff --git a/npm_modules/cli/src/commands/debugger.ts b/npm_modules/cli/src/commands/debugger.ts
index d1d3af308..cdfce1fb3 100644
--- a/npm_modules/cli/src/commands/debugger.ts
+++ b/npm_modules/cli/src/commands/debugger.ts
@@ -1,52 +1,117 @@
+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 { resolveDebuggerUiPort, 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({
url: debuggerServer.url,
+ apiToken: debuggerServer.apiToken,
+ apiTokenHeader: debuggerServer.apiTokenHeader,
host: debuggerServer.host,
port: debuggerServer.port,
requestedPort: debuggerServer.requestedPort,
portWasAutoSelected: debuggerServer.portWasAutoSelected,
pid: process.pid,
+ ...(extensionDirectory ? { extensionDirectory } : {}),
+ ...(inspectedUrl ? { inspectedUrl: inspectedUrl.toString(), chromiumDebuggingPort } : {}),
}),
);
} else {
@@ -55,9 +120,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';
@@ -72,7 +144,7 @@ export const builder = (yargs: Argv) => {
.option('port', {
describe: 'Preferred debugger web server port',
type: 'number',
- default: Number.parseInt(process.env['VALDI_DEBUGGER_PORT'] || '8765', 10),
+ default: resolveDebuggerUiPort(),
})
.option('strict-port', {
describe: 'Fail instead of selecting the next available port when the preferred port is busy',
@@ -83,6 +155,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/commands/inspect.ts b/npm_modules/cli/src/commands/inspect.ts
index 63dabae14..c1228eb6e 100644
--- a/npm_modules/cli/src/commands/inspect.ts
+++ b/npm_modules/cli/src/commands/inspect.ts
@@ -4,8 +4,11 @@ export const command = 'inspect ';
export const describe = 'Inspect a running Valdi app — component trees, contexts, screenshots, heap';
export const builder = (yargs: Argv) => {
return yargs
- .commandDir('inspect_commands', { extensions: ['js', 'ts'] })
- .demandCommand(1, 'Use devices, select, status, contexts, tree, snapshot, or heap')
+ .commandDir('inspect_commands', {
+ extensions: ['js', 'ts'],
+ exclude: /\.spec\.(js|ts)$/,
+ })
+ .demandCommand(1, 'Use devices, select, status, contexts, tree, snapshot, input, or heap')
.recommendCommands()
.wrap(yargs.terminalWidth())
.help();
diff --git a/npm_modules/cli/src/commands/inspect_commands/input.spec.ts b/npm_modules/cli/src/commands/inspect_commands/input.spec.ts
new file mode 100644
index 000000000..185c06ccd
--- /dev/null
+++ b/npm_modules/cli/src/commands/inspect_commands/input.spec.ts
@@ -0,0 +1,203 @@
+import 'jasmine';
+import { DebuggerInputType, unwrapDebuggerInputResponse } from '../../debugger/inputClient';
+import { buildInputRequest } from './input';
+
+describe('inspect input', () => {
+ it('builds a query using a stable accessibility selector', () => {
+ expect(
+ buildInputRequest({
+ action: DebuggerInputType.Query,
+ selector: '#composer',
+ focused: true,
+ }),
+ ).toEqual({
+ type: 'query',
+ selector: '#composer',
+ });
+ });
+
+ it('builds context-free capabilities without a selector', () => {
+ expect(
+ buildInputRequest({
+ action: DebuggerInputType.Capabilities,
+ focused: true,
+ }),
+ ).toEqual({ type: DebuggerInputType.Capabilities });
+ });
+
+ it('parses structured selectors and input details', () => {
+ expect(
+ buildInputRequest({
+ action: DebuggerInputType.Text,
+ selector: '{"accessibilityId":"composer","tag":"textfield"}',
+ focused: true,
+ text: 'hello',
+ selectionStart: 5,
+ selectionEnd: 5,
+ }),
+ ).toEqual({
+ type: 'text',
+ selector: {
+ accessibilityId: 'composer',
+ tag: 'textfield',
+ },
+ text: 'hello',
+ selectionStart: 5,
+ selectionEnd: 5,
+ });
+ });
+
+ it('builds focus, key, tap, and scroll operations', () => {
+ expect(
+ buildInputRequest({
+ action: DebuggerInputType.Focus,
+ accessibilityId: 'composer',
+ focused: false,
+ }),
+ ).toEqual({
+ type: 'focus',
+ accessibilityId: 'composer',
+ focused: false,
+ });
+ expect(
+ buildInputRequest({
+ action: DebuggerInputType.Key,
+ elementId: 12,
+ focused: true,
+ key: 'Enter',
+ }),
+ ).toEqual({
+ type: 'key',
+ elementId: 12,
+ key: 'Enter',
+ });
+ expect(
+ buildInputRequest({
+ action: DebuggerInputType.Key,
+ elementId: 12,
+ focused: true,
+ key: '👍🏽',
+ }),
+ ).toEqual({
+ type: 'key',
+ elementId: 12,
+ key: '👍🏽',
+ });
+ expect(
+ buildInputRequest({
+ action: DebuggerInputType.Tap,
+ selector: '#send',
+ focused: true,
+ x: 10,
+ y: 20,
+ }),
+ ).toEqual({
+ type: 'tap',
+ selector: '#send',
+ x: 10,
+ y: 20,
+ });
+ expect(
+ buildInputRequest({
+ action: DebuggerInputType.Scroll,
+ accessibilityId: 'messages',
+ focused: true,
+ deltaX: 2,
+ deltaY: 100,
+ }),
+ ).toEqual({
+ type: 'scroll',
+ accessibilityId: 'messages',
+ deltaX: 2,
+ deltaY: 100,
+ });
+ });
+
+ it('validates selectors and action-specific values', () => {
+ expect(() =>
+ buildInputRequest({
+ action: DebuggerInputType.Tap,
+ elementId: 1,
+ accessibilityId: 'send',
+ focused: true,
+ }),
+ ).toThrowError('Use only one of --element-id, --accessibility-id, or --selector.');
+ expect(() =>
+ buildInputRequest({
+ action: DebuggerInputType.Text,
+ accessibilityId: 'composer',
+ focused: true,
+ }),
+ ).toThrowError('The text action requires --text.');
+ expect(() =>
+ buildInputRequest({
+ action: DebuggerInputType.Key,
+ accessibilityId: 'composer',
+ focused: true,
+ }),
+ ).toThrowError('The key action requires --key.');
+ expect(() =>
+ buildInputRequest({
+ action: DebuggerInputType.Tap,
+ focused: true,
+ x: Number.NaN,
+ }),
+ ).toThrowError('An elementId, accessibilityId, or selector is required.');
+ expect(() =>
+ buildInputRequest({
+ action: DebuggerInputType.Query,
+ selector: '{"elementId":1.5}',
+ focused: true,
+ }),
+ ).toThrowError('elementId must be a finite integer.');
+ expect(() =>
+ buildInputRequest({
+ action: DebuggerInputType.Text,
+ elementId: 1,
+ focused: true,
+ text: String.fromCodePoint(0xd8_3d),
+ }),
+ ).toThrowError('text must contain valid Unicode.');
+ expect(() =>
+ buildInputRequest({
+ action: DebuggerInputType.Key,
+ elementId: 1,
+ focused: true,
+ key: 'abc',
+ }),
+ ).toThrowError('key must be Enter, Return, Escape, Backspace, Delete, or one printable grapheme.');
+ });
+
+ it('unwraps target responses into one stable result object', () => {
+ expect(
+ unwrapDebuggerInputResponse(
+ {
+ handled: true,
+ data: {
+ contractVersion: 1,
+ handled: true,
+ type: 'capabilities',
+ action: 'capabilities',
+ supportedTypes: Object.values(DebuggerInputType),
+ selectorForms: ['elementId'],
+ },
+ },
+ { type: 'capabilities' },
+ ),
+ ).toEqual({
+ contractVersion: 1,
+ handled: true,
+ type: 'capabilities',
+ action: 'capabilities',
+ supportedTypes: Object.values(DebuggerInputType),
+ selectorForms: ['elementId'],
+ });
+
+ expect(unwrapDebuggerInputResponse({ handled: false }, { type: 'tap', elementId: 3 })).toEqual({
+ handled: false,
+ type: 'tap',
+ elementId: 3,
+ message: 'The target app did not register the Valdi debugger input handler.',
+ });
+ });
+});
diff --git a/npm_modules/cli/src/commands/inspect_commands/input.ts b/npm_modules/cli/src/commands/inspect_commands/input.ts
new file mode 100644
index 000000000..da50de166
--- /dev/null
+++ b/npm_modules/cli/src/commands/inspect_commands/input.ts
@@ -0,0 +1,225 @@
+import type { Argv } from 'yargs';
+import { DebuggerInputType, sendDebuggerInput, validateDebuggerInputRequest } from '../../debugger/inputClient';
+import type { ArgumentsResolver } from '../../utils/ArgumentsResolver';
+import { DEFAULT_PORT, connectToDaemon, resolveClientId, resolveContextId } from '../../utils/daemonClient';
+import { makeCommandHandler } from '../../utils/errorUtils';
+
+interface CommandParameters {
+ action: DebuggerInputType;
+ contextId: string | undefined;
+ port: number;
+ client: string | undefined;
+ elementId: number | undefined;
+ accessibilityId: string | undefined;
+ selector: string | undefined;
+ focused: boolean;
+ text: string | undefined;
+ key: string | undefined;
+ selectionStart: number | undefined;
+ selectionEnd: number | undefined;
+ x: number | undefined;
+ y: number | undefined;
+ deltaX: number | undefined;
+ deltaY: number | undefined;
+}
+
+interface InputSelector {
+ elementId?: number | undefined;
+ accessibilityId?: string | undefined;
+ tag?: string | undefined;
+}
+
+interface InputCommandArguments {
+ action: DebuggerInputType;
+ elementId?: number | undefined;
+ accessibilityId?: string | undefined;
+ selector?: string | undefined;
+ focused: boolean;
+ text?: string | undefined;
+ key?: string | undefined;
+ selectionStart?: number | undefined;
+ selectionEnd?: number | undefined;
+ x?: number | undefined;
+ y?: number | undefined;
+ deltaX?: number | undefined;
+ deltaY?: number | undefined;
+}
+
+const INPUT_SELECTOR_FIELDS: ReadonlySet = new Set(['elementId', 'accessibilityId', 'tag']);
+
+function parseSelector(selector: string): string | InputSelector {
+ const trimmed = selector.trim();
+ if (!trimmed.startsWith('{')) {
+ return selector;
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch {
+ throw new Error('--selector must be a selector string or a JSON object.');
+ }
+ if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') {
+ throw new Error('--selector JSON must be an object.');
+ }
+
+ const record = parsed as Record;
+ const unknownKey = Object.keys(record)
+ .sort()
+ .find(key => !INPUT_SELECTOR_FIELDS.has(key));
+ if (unknownKey) {
+ throw new Error(`Unsupported selector field "${unknownKey}".`);
+ }
+ return record as InputSelector;
+}
+
+function addDefined(request: Record, key: string, value: unknown): void {
+ if (value !== undefined) {
+ request[key] = value;
+ }
+}
+
+export function buildInputRequest(args: InputCommandArguments): Record {
+ const selectorCount = [args.elementId, args.accessibilityId, args.selector].filter(
+ value => value !== undefined,
+ ).length;
+ if (selectorCount > 1) {
+ throw new Error('Use only one of --element-id, --accessibility-id, or --selector.');
+ }
+ if (args.action === DebuggerInputType.Text && args.text === undefined) {
+ throw new Error('The text action requires --text.');
+ }
+ if (args.action === DebuggerInputType.Key && args.key === undefined) {
+ throw new Error('The key action requires --key.');
+ }
+
+ const request: Record = { type: args.action };
+ addDefined(request, 'elementId', args.elementId);
+ addDefined(request, 'accessibilityId', args.accessibilityId);
+ addDefined(request, 'selector', args.selector === undefined ? undefined : parseSelector(args.selector));
+ if (args.action === DebuggerInputType.Focus) {
+ request['focused'] = args.focused;
+ }
+ addDefined(request, 'text', args.text);
+ addDefined(request, 'key', args.key);
+ addDefined(request, 'selectionStart', args.selectionStart);
+ addDefined(request, 'selectionEnd', args.selectionEnd);
+ addDefined(request, 'x', args.x);
+ addDefined(request, 'y', args.y);
+ addDefined(request, 'deltaX', args.deltaX);
+ addDefined(request, 'deltaY', args.deltaY);
+ const validationError = validateDebuggerInputRequest(request);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+ return request;
+}
+
+async function inspectInput(argv: ArgumentsResolver): Promise {
+ const action = argv.getArgument('action');
+ const contextIdArg = argv.getArgument('contextId');
+ const port = argv.getArgument('port');
+ const clientOverride = argv.getArgument('client');
+ const request = buildInputRequest({
+ action,
+ elementId: argv.getArgument('elementId'),
+ accessibilityId: argv.getArgument('accessibilityId'),
+ selector: argv.getArgument('selector'),
+ focused: argv.getArgument('focused'),
+ text: argv.getArgument('text'),
+ key: argv.getArgument('key'),
+ selectionStart: argv.getArgument('selectionStart'),
+ selectionEnd: argv.getArgument('selectionEnd'),
+ x: argv.getArgument('x'),
+ y: argv.getArgument('y'),
+ deltaX: argv.getArgument('deltaX'),
+ deltaY: argv.getArgument('deltaY'),
+ });
+
+ const conn = await connectToDaemon(port);
+ try {
+ await conn.configure();
+ const clientId = await resolveClientId(conn, clientOverride);
+ const contextId =
+ action === DebuggerInputType.Capabilities ? undefined : await resolveContextId(conn, clientId, contextIdArg);
+ const requestWithContext = contextId === undefined ? request : { ...request, contextId };
+ const input = await sendDebuggerInput(conn, clientId, requestWithContext);
+ console.log(JSON.stringify({ port, clientId, contextId, input }));
+ } finally {
+ conn.close();
+ }
+}
+
+export const command = 'input [contextId]';
+export const describe = 'Query and control a live Valdi app through the default debugger input contract';
+export const builder = (yargs: Argv) => {
+ yargs
+ .positional('action', {
+ describe: 'Input operation',
+ choices: Object.values(DebuggerInputType),
+ type: 'string',
+ })
+ .positional('contextId', {
+ describe: 'Context ID (omit to auto-select or be prompted)',
+ type: 'string',
+ })
+ .option('port', {
+ describe: 'Daemon TCP port (use 13591 for standalone macOS apps)',
+ type: 'number',
+ default: DEFAULT_PORT,
+ })
+ .option('client', {
+ describe: 'Client ID to target (from "valdi inspect devices")',
+ type: 'string',
+ })
+ .option('element-id', {
+ describe: 'Target a rendered element ID',
+ type: 'number',
+ })
+ .option('accessibility-id', {
+ describe: 'Target a stable accessibility ID',
+ type: 'string',
+ })
+ .option('selector', {
+ describe: 'Target #accessibilityId, [accessibilityId="..."], or a JSON selector object',
+ type: 'string',
+ })
+ .option('focused', {
+ describe: 'Focus state for the focus action; use --no-focused to blur',
+ type: 'boolean',
+ default: true,
+ })
+ .option('text', {
+ describe: 'Replacement text for the text action',
+ type: 'string',
+ })
+ .option('key', {
+ describe: 'Key for the key action (Enter, Return, Escape, Backspace, Delete, or one printable grapheme)',
+ type: 'string',
+ })
+ .option('selection-start', {
+ describe: 'Selection start for text and key actions',
+ type: 'number',
+ })
+ .option('selection-end', {
+ describe: 'Selection end for text and key actions',
+ type: 'number',
+ })
+ .option('x', {
+ describe: 'Absolute X coordinate for tap',
+ type: 'number',
+ })
+ .option('y', {
+ describe: 'Absolute Y coordinate for tap',
+ type: 'number',
+ })
+ .option('delta-x', {
+ describe: 'Horizontal content-offset delta for scroll',
+ type: 'number',
+ })
+ .option('delta-y', {
+ describe: 'Vertical content-offset delta for scroll',
+ type: 'number',
+ });
+};
+export const handler = makeCommandHandler(inspectInput);
diff --git a/npm_modules/cli/src/core/packageFiles.spec.ts b/npm_modules/cli/src/core/packageFiles.spec.ts
index e396889b9..d5eec5a96 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/');
});
@@ -82,18 +86,44 @@ describe('npm package contents', () => {
for (const deferredSurface of [
'renderDataProviders',
'renderNetwork',
- 'dispatchDebuggerInput',
- 'capturePerformanceTrace',
'renderWebPreviewFrame',
]) {
expect(orderedBundle).not.toContain(deferredSurface);
}
+ expect(orderedBundle).toContain('dispatchDebuggerInput');
+ expect(orderedBundle).toContain("apiPost('/api/input'");
+
+ const devToolsPanelAssets = [
+ 'devtools-panel.css',
+ 'devtools-panel.html',
+ 'devtools-panel.js',
+ 'debugger-tree-model.js',
+ ];
+ for (const assetPath of devToolsPanelAssets) {
+ expect(packedFiles).toContain(`debugger/${assetPath}`);
+ }
+ const devToolsPanelHtml = fs.readFileSync(path.join(debuggerRoot, 'devtools-panel.html'), 'utf8');
+ const treeModelScriptIndex = devToolsPanelHtml.indexOf('debugger-tree-model.js');
+ const panelScriptIndex = devToolsPanelHtml.indexOf('devtools-panel.js');
+ expect(treeModelScriptIndex).toBeGreaterThan(-1);
+ expect(panelScriptIndex).toBeGreaterThan(treeModelScriptIndex);
+ expect(
+ () =>
+ new vm.Script(
+ [
+ fs.readFileSync(path.join(debuggerRoot, 'debugger-tree-model.js'), 'utf8'),
+ fs.readFileSync(path.join(debuggerRoot, 'devtools-panel.js'), 'utf8'),
+ ].join('\n'),
+ { filename: 'valdi-devtools-panel.js' },
+ ),
+ ).not.toThrow();
});
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=='),
@@ -106,10 +136,979 @@ describe('npm package contents', () => {
expect(previewSource).not.toContain('frame.src = source');
});
+ 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(
+ `${treeModelSource}\n${previewSource}
+ [
+ isHtmlPreviewEffectivelyDisabled({ attributes: {} }, true),
+ isHtmlPreviewEffectivelyDisabled({ attributes: { enabled: false } }, false),
+ isHtmlPreviewEffectivelyDisabled({ attributes: { accessibilityStateDisabled: true } }, false),
+ isHtmlPreviewEffectivelyDisabled({ attributes: { touchEnabled: false } }, false),
+ isHtmlPreviewEffectivelyDisabled({ attributes: { enabled: true, touchEnabled: true } }, false),
+ ];`,
+ { filename: 'debugger-preview-html.js' },
+ ).runInNewContext({
+ getNodeAttributes: (node: { attributes: Record }) => node.attributes,
+ }) as boolean[];
+
+ expect(disabledStates).toEqual([true, true, true, true, false]);
+ });
+
+ 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<{
+ options: Record;
+ payload: Record;
+ target: Record;
+ }> = [];
+ const timers = new Map void>();
+ let nextTimerId = 1;
+ class FakeInputElement {
+ readonly dataset = { previewElementId: '42', previewNodeId: 'node-42' };
+ readonly isContentEditable = false;
+ readonly selectionEnd = 5;
+ readonly selectionStart = 5;
+ readonly value = 'draft';
+
+ closest(): FakeInputElement {
+ return this;
+ }
+
+ blur(): void {}
+ }
+ class FakeTextAreaElement {}
+ const editableTarget = new FakeInputElement();
+ const enqueueInput = (
+ target: Record,
+ payload: Record,
+ options: Record,
+ ): Promise<{ handled: boolean }> => {
+ dispatchedInputs.push({ options, payload, target });
+ return Promise.resolve({ handled: true });
+ };
+ const operation = new vm.Script(
+ `${treeModelSource}\n${previewSource}
+ (async () => {
+ const target = Object.freeze({ port: 13_591, clientId: 'client-a', contextId: 'context-a' });
+ associateHtmlPreviewElement(editableTarget, target);
+ const event = {
+ target: editableTarget,
+ preventDefault() {},
+ stopPropagation() {},
+ };
+ await dispatchHtmlPreviewFocusInput(event, true);
+ await dispatchHtmlPreviewTapInput(event);
+ dispatchHtmlPreviewTextInput(event);
+ await dispatchHtmlPreviewKeyInput({ ...event, key: 'Enter' });
+ await dispatchHtmlPreviewFocusInput(event, false);
+ })();`,
+ { filename: 'debugger-preview-html.js' },
+ ).runInNewContext({
+ HTMLInputElement: FakeInputElement,
+ HTMLTextAreaElement: FakeTextAreaElement,
+ debuggerInputTargetElementKey: (
+ target: { port: number; clientId: string; contextId: string },
+ elementId: number,
+ ) => `${target.port}:${target.clientId}:${target.contextId}:${elementId}`,
+ enqueueDebuggerInput: enqueueInput,
+ document: { activeElement: editableTarget },
+ editableTarget,
+ elements: { htmlPreviewRoot: { contains: () => true } },
+ findNode: () => ({ attributes: { closesWhenReturnKeyPressed: false }, tag: 'textfield' }),
+ getNodeAttributes: (node: { attributes: Record }) => node.attributes,
+ reserveDebuggerInput: (target: Record) => ({
+ cancel: () => Promise.resolve(null),
+ dispatch: (payload: Record, options: Record) =>
+ enqueueInput(target, payload, options),
+ }),
+ selectPreviewNode: jasmine.createSpy('selectPreviewNode'),
+ window: {
+ clearTimeout: (timerId: number) => timers.delete(timerId),
+ setTimeout: (callback: () => void) => {
+ const timerId = nextTimerId;
+ nextTimerId += 1;
+ timers.set(timerId, callback);
+ return timerId;
+ },
+ },
+ }) as Promise;
+
+ await operation;
+
+ expect(dispatchedInputs.map(input => input.payload['type'])).toEqual(['focus', 'text', 'key', 'focus']);
+ expect(dispatchedInputs[0]?.payload['focused']).toBeTrue();
+ expect(dispatchedInputs[3]?.payload['focused']).toBeFalse();
+ expect(dispatchedInputs[0]?.options['refresh']).toBeFalse();
+ expect(dispatchedInputs[3]?.options['refresh']).toBeTrue();
+ expect(dispatchedInputs.every(input => input.target['contextId'] === 'context-a')).toBeTrue();
+ expect(bootstrapSource).toContain("addEventListener('focusin'");
+ expect(bootstrapSource).toContain("addEventListener('focusout'");
+ });
+
+ 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 {}
+ class FakeTextAreaElement {
+ readonly dataset = { previewElementId: '42', previewNodeId: 'node-42' };
+ readonly isContentEditable = false;
+ selectionEnd = 1;
+ selectionStart = 1;
+ value = 'ab';
+
+ blur(): void {}
+
+ closest(): FakeTextAreaElement {
+ return this;
+ }
+
+ setSelectionRange(selectionStart: number, selectionEnd: number): void {
+ this.selectionStart = selectionStart;
+ this.selectionEnd = selectionEnd;
+ }
+ }
+ const textarea = new FakeTextAreaElement();
+ const operation = new vm.Script(
+ `${treeModelSource}\n${previewSource}
+ (async () => {
+ const target = Object.freeze({ port: 13_591, clientId: 'client-a', contextId: 'context-a' });
+ associateHtmlPreviewElement(textarea, target);
+ let browserInputEventCount = 0;
+ const event = {
+ target: textarea,
+ key: 'Enter',
+ defaultPrevented: false,
+ preventDefault() { this.defaultPrevented = true; },
+ stopPropagation() {},
+ };
+ const keyDispatch = dispatchHtmlPreviewKeyInput(event);
+ const valueBeforeRuntimeResponse = textarea.value;
+
+ // Model the browser's default phase after keydown: an unprevented textarea Return mutates
+ // the DOM and emits input, which would otherwise enqueue a second debugger text operation.
+ if (!event.defaultPrevented) {
+ textarea.value = 'a\\nb';
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+ browserInputEventCount += 1;
+ dispatchHtmlPreviewTextInput({ target: textarea });
+ }
+
+ await keyDispatch;
+ return {
+ browserInputEventCount,
+ defaultPrevented: event.defaultPrevented,
+ selectionEnd: textarea.selectionEnd,
+ selectionStart: textarea.selectionStart,
+ value: textarea.value,
+ valueBeforeRuntimeResponse,
+ };
+ })();`,
+ { filename: 'debugger-preview-html.js' },
+ ).runInNewContext({
+ HTMLInputElement: FakeInputElement,
+ HTMLTextAreaElement: FakeTextAreaElement,
+ debuggerInputTargetElementKey: (
+ target: { port: number; clientId: string; contextId: string },
+ elementId: number,
+ ) => `${target.port}:${target.clientId}:${target.contextId}:${elementId}`,
+ document: { activeElement: textarea },
+ elements: { htmlPreviewRoot: { contains: () => true } },
+ enqueueDebuggerInput: (_target: Record, payload: Record) => {
+ dispatchedInputs.push(payload);
+ return Promise.resolve({
+ action: 'onReturn',
+ handled: true,
+ selectionEnd: 2,
+ selectionStart: 2,
+ value: 'A\nB',
+ });
+ },
+ findNode: () => ({ attributes: { closesWhenReturnKeyPressed: false }, tag: 'textview' }),
+ getNodeAttributes: (node: { attributes: Record }) => node.attributes,
+ reserveDebuggerInput: () => ({
+ cancel: () => Promise.resolve(null),
+ dispatch: () => Promise.resolve(null),
+ }),
+ selectPreviewNode: jasmine.createSpy('selectPreviewNode'),
+ textarea,
+ window: {
+ clearTimeout() {},
+ setTimeout: () => 1,
+ },
+ }) as Promise<{
+ browserInputEventCount: number;
+ defaultPrevented: boolean;
+ selectionEnd: number;
+ selectionStart: number;
+ value: string;
+ valueBeforeRuntimeResponse: string;
+ }>;
+
+ expect(await operation).toEqual({
+ browserInputEventCount: 0,
+ defaultPrevented: true,
+ selectionEnd: 2,
+ selectionStart: 2,
+ value: 'A\nB',
+ valueBeforeRuntimeResponse: 'a\nb',
+ });
+ expect(dispatchedInputs).toEqual([
+ jasmine.objectContaining({
+ elementId: 42,
+ key: 'Enter',
+ selectionEnd: 1,
+ selectionStart: 1,
+ type: 'key',
+ }),
+ ]);
+ });
+
+ 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[] = [];
+ const timers = new Map void>();
+ let nextTimerId = 1;
+ class FakeInputElement {}
+ class FakeTextAreaElement {
+ readonly dataset = { previewElementId: '42', previewNodeId: 'node-42' };
+ readonly isContentEditable = false;
+ blurCount = 0;
+ selectionEnd = 1;
+ selectionStart = 1;
+ value = 'ab';
+
+ blur(): void {
+ this.blurCount += 1;
+ }
+
+ closest(): FakeTextAreaElement {
+ return this;
+ }
+
+ setSelectionRange(selectionStart: number, selectionEnd: number): void {
+ this.selectionStart = selectionStart;
+ this.selectionEnd = selectionEnd;
+ }
+ }
+ const textarea = new FakeTextAreaElement();
+ const operation = new vm.Script(
+ `${treeModelSource}\n${previewSource}
+ (async () => {
+ const target = Object.freeze({ port: 13_591, clientId: 'client-a', contextId: 'context-a' });
+ associateHtmlPreviewElement(textarea, target);
+ const baseEvent = { target: textarea, preventDefault() {}, stopPropagation() {} };
+ await dispatchHtmlPreviewFocusInput(baseEvent, true);
+ const keyDispatch = dispatchHtmlPreviewKeyInput({ ...baseEvent, key: 'Enter' });
+
+ textarea.value = 'edited-away';
+ textarea.selectionStart = textarea.value.length;
+ textarea.selectionEnd = textarea.value.length;
+ dispatchHtmlPreviewTextInput(baseEvent);
+ textarea.value = 'a\\nb';
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+ dispatchHtmlPreviewTextInput(baseEvent);
+
+ await dispatchHtmlPreviewFocusInput(baseEvent, false);
+ await dispatchHtmlPreviewFocusInput(baseEvent, true);
+ resolveKeyResponse();
+ await keyDispatch;
+ return { blurCount: textarea.blurCount, value: textarea.value };
+ })();`,
+ { filename: 'debugger-preview-html.js' },
+ ).runInNewContext({
+ HTMLInputElement: FakeInputElement,
+ HTMLTextAreaElement: FakeTextAreaElement,
+ debuggerInputTargetElementKey: (
+ target: { port: number; clientId: string; contextId: string },
+ elementId: number,
+ ) => `${target.port}:${target.clientId}:${target.contextId}:${elementId}`,
+ document: { activeElement: textarea },
+ elements: { htmlPreviewRoot: { contains: () => true } },
+ enqueueDebuggerInput: (
+ _target: Record,
+ payload: Record,
+ ): Promise> => {
+ dispatchedTypes.push(String(payload['type']));
+ return payload['type'] === 'key' ? keyResponse.promise : Promise.resolve({ handled: true });
+ },
+ findNode: () => ({ attributes: { closesWhenReturnKeyPressed: true }, tag: 'textview' }),
+ getNodeAttributes: (node: { attributes: Record }) => node.attributes,
+ reserveDebuggerInput: () => ({
+ cancel: () => Promise.resolve(null),
+ dispatch: (payload: Record) => {
+ dispatchedTypes.push(String(payload['type']));
+ return Promise.resolve({
+ handled: true,
+ selectionEnd: payload['selectionEnd'],
+ selectionStart: payload['selectionStart'],
+ value: payload['text'],
+ });
+ },
+ }),
+ resolveKeyResponse: () => {
+ keyResponse.resolve({
+ action: 'onReturn',
+ handled: true,
+ selectionEnd: 2,
+ selectionStart: 2,
+ value: 'runtime-stale',
+ });
+ },
+ selectPreviewNode: () => {},
+ textarea,
+ window: {
+ clearTimeout: (timerId: number) => timers.delete(timerId),
+ setTimeout: (callback: () => void) => {
+ const timerId = nextTimerId;
+ nextTimerId += 1;
+ timers.set(timerId, callback);
+ return timerId;
+ },
+ },
+ }) as Promise<{ blurCount: number; value: string }>;
+ expect(await operation).toEqual({ blurCount: 0, value: 'a\nb' });
+ expect(dispatchedTypes).toEqual(['focus', 'key', 'text', 'focus', 'focus']);
+ });
+
+ 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 }> = [];
+ const timers = new Map void>();
+ let nextReservationId = 1;
+ let nextTimerId = 1;
+ let hasTree = true;
+ class FakePreviewElement {
+ readonly dataset: { previewElementId: string; previewNodeId: string };
+
+ constructor(nodeId: string, elementId: number) {
+ this.dataset = { previewElementId: String(elementId), previewNodeId: nodeId };
+ }
+
+ closest(): FakePreviewElement {
+ return this;
+ }
+ }
+ class FakeInputElement extends FakePreviewElement {
+ readonly isContentEditable = false;
+ selectionEnd: number;
+ selectionStart: number;
+ value: string;
+
+ constructor(nodeId: string, elementId: number, value: string) {
+ super(nodeId, elementId);
+ this.value = value;
+ this.selectionEnd = value.length;
+ this.selectionStart = value.length;
+ }
+
+ setSelectionRange(selectionStart: number, selectionEnd: number): void {
+ this.selectionStart = selectionStart;
+ this.selectionEnd = selectionEnd;
+ }
+ }
+ class FakeTextAreaElement extends FakePreviewElement {}
+ const oldInput = new FakeInputElement('old-input', 42, 'old');
+ const oldScroll = new FakePreviewElement('old-scroll', 42);
+ const newInput = new FakeInputElement('new-input', 42, 'new');
+ const nodes: Record; elementId: number; tag: string }> = {
+ 'new-input': { attributes: {}, elementId: 42, tag: 'textfield' },
+ 'old-input': { attributes: {}, elementId: 42, tag: 'textfield' },
+ 'old-scroll': { attributes: {}, elementId: 42, tag: 'scroll' },
+ };
+ const target = Object.freeze({ port: 13_591, clientId: 'client-a', contextId: 'context-a' });
+ const operation = new vm.Script(
+ `${treeModelSource}\n${previewSource}
+ (async () => {
+ associateHtmlPreviewElement(oldInput, target);
+ associateHtmlPreviewElement(oldScroll, target);
+ dispatchHtmlPreviewTextInput({ target: oldInput });
+ dispatchHtmlPreviewScrollInput({
+ target: oldScroll,
+ deltaX: 1,
+ deltaY: 2,
+ preventDefault() {},
+ stopPropagation() {},
+ });
+
+ setHasTree(false);
+ renderHtmlPreview();
+ associateHtmlPreviewElement(newInput, target);
+ dispatchHtmlPreviewTextInput({ target: newInput });
+ runAllTimers();
+ await settlePromises();
+ return {
+ incarnation: htmlPreviewIncarnation,
+ pendingScrollCount: htmlPreviewScrollInputs.size,
+ pendingTextCount: htmlPreviewTextInputs.size,
+ };
+ })();`,
+ { filename: 'debugger-preview-html.js' },
+ ).runInNewContext({
+ HTMLInputElement: FakeInputElement,
+ HTMLTextAreaElement: FakeTextAreaElement,
+ debuggerInputTargetElementKey: (
+ inputTarget: { port: number; clientId: string; contextId: string },
+ elementId: number,
+ ) => `${inputTarget.port}:${inputTarget.clientId}:${inputTarget.contextId}:${elementId}`,
+ elements: {
+ device: { style: { setProperty() {} } },
+ htmlPreviewRoot: {
+ classList: { toggle() {} },
+ contains: () => true,
+ replaceChildren() {},
+ },
+ },
+ findNode: (nodeId: string) => nodes[nodeId],
+ findNodeAtPoint: (
+ _point: unknown,
+ predicate: (node: { attributes: Record; elementId: number; tag: string }) => boolean,
+ ) => {
+ const node = nodes['old-scroll'];
+ return node !== undefined && predicate(node) ? node : undefined;
+ },
+ getElementIdForNode: (node: { elementId: number }) => node.elementId,
+ getNodeAttributes: (node: { attributes: Record }) => node.attributes,
+ hasScrollState: () => false,
+ hasSnapshotTree: () => hasTree,
+ newInput,
+ nodes,
+ oldInput,
+ oldScroll,
+ pointFromScreenEvent: () => ({ x: 10, y: 20 }),
+ reserveDebuggerInput: () => {
+ const reservationId = nextReservationId;
+ nextReservationId += 1;
+ return {
+ cancel: () => {
+ cancelledReservations.push(reservationId);
+ return Promise.resolve(null);
+ },
+ dispatch: (payload: Record) => {
+ dispatchedInputs.push({ payload, reservationId });
+ return Promise.resolve({
+ handled: true,
+ selectionEnd: payload['selectionEnd'],
+ selectionStart: payload['selectionStart'],
+ value: payload['text'],
+ });
+ },
+ };
+ },
+ runAllTimers: () => {
+ while (timers.size > 0) {
+ const callbacks = Array.from(timers.values());
+ timers.clear();
+ callbacks.forEach(callback => callback());
+ }
+ },
+ selectPreviewNode: () => {},
+ setHasTree: (value: boolean) => {
+ hasTree = value;
+ },
+ settlePromises: async () => {
+ for (let index = 0; index < 8; index += 1) await Promise.resolve();
+ },
+ state: { source: 'daemon' },
+ target,
+ window: {
+ clearTimeout: (timerId: number) => timers.delete(timerId),
+ setTimeout: (callback: () => void) => {
+ const timerId = nextTimerId;
+ nextTimerId += 1;
+ timers.set(timerId, callback);
+ return timerId;
+ },
+ },
+ }) as Promise<{ incarnation: number; pendingScrollCount: number; pendingTextCount: number }>;
+
+ expect(await operation).toEqual({ incarnation: 1, pendingScrollCount: 0, pendingTextCount: 0 });
+ expect(cancelledReservations).toEqual([1, 2]);
+ expect(dispatchedInputs).toEqual([
+ {
+ payload: jasmine.objectContaining({ elementId: 42, text: 'new', type: 'text' }),
+ reservationId: 3,
+ },
+ ]);
+ });
+
+ 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 } }>();
+ interface FakeNode {
+ attributes: Record;
+ elementId: number;
+ id: string;
+ tag: string;
+ }
+ interface FakeTimer {
+ callback: () => void;
+ dueAt: number;
+ }
+ const requests: Array> = [];
+ const timers = new Map();
+ let currentTime = 0;
+ let nextTimerId = 1;
+ let hasTree = true;
+ const runAllTimers = (): void => {
+ while (timers.size > 0) {
+ const nextTimer = Array.from(timers.entries()).sort(
+ ([firstId, first], [secondId, second]) => first.dueAt - second.dueAt || firstId - secondId,
+ )[0];
+ if (!nextTimer) throw new Error('Expected a scheduled timer.');
+ const [timerId, timer] = nextTimer;
+ timers.delete(timerId);
+ currentTime = timer.dueAt;
+ timer.callback();
+ }
+ };
+ const settlePromises = async (): Promise => {
+ for (let index = 0; index < 12; index += 1) await Promise.resolve();
+ };
+ class FakePreviewElement {
+ readonly dataset: { previewElementId: string; previewNodeId: string };
+
+ constructor(nodeId: string, elementId: number) {
+ this.dataset = { previewElementId: String(elementId), previewNodeId: nodeId };
+ }
+
+ closest(): FakePreviewElement {
+ return this;
+ }
+ }
+ class FakeInputElement extends FakePreviewElement {
+ readonly isContentEditable = false;
+ readonly selectionEnd: number;
+ readonly selectionStart: number;
+ readonly value: string;
+
+ constructor(nodeId: string, value: string) {
+ super(nodeId, 42);
+ this.selectionEnd = value.length;
+ this.selectionStart = value.length;
+ this.value = value;
+ }
+ }
+ class FakeTextAreaElement extends FakePreviewElement {}
+ const oldInput = new FakeInputElement('old-input', 'old');
+ const oldScroll = new FakePreviewElement('old-scroll', 43);
+ const newInput = new FakeInputElement('new-input', 'new');
+ const newScroll = new FakePreviewElement('new-scroll', 43);
+ const nodes: Record = {
+ 'new-input': { attributes: {}, elementId: 42, id: 'new-input', tag: 'textfield' },
+ 'new-scroll': { attributes: {}, elementId: 43, id: 'new-scroll', tag: 'scroll' },
+ 'old-input': { attributes: {}, elementId: 42, id: 'old-input', tag: 'textfield' },
+ 'old-scroll': { attributes: {}, elementId: 43, id: 'old-scroll', tag: 'scroll' },
+ };
+ const activeScrollNode = { current: nodes['old-scroll'] };
+ const target = Object.freeze({ port: 13_591, clientId: 'client-a', contextId: 'context-a' });
+ const state = {
+ source: 'daemon',
+ inputRefreshTimers: new Map(),
+ snapshot: { target: { proxyPort: target.port, clientId: target.clientId, contextId: target.contextId } },
+ };
+ const vmContext = vm.createContext({
+ HTMLInputElement: FakeInputElement,
+ HTMLTextAreaElement: FakeTextAreaElement,
+ activeScrollNode,
+ addLog: () => {},
+ apiPost: (_path: string, _params: Record, payload: Record) => {
+ requests.push(payload);
+ return payload['elementId'] === 99
+ ? firstResponse.promise
+ : Promise.resolve({ input: { handled: true, value: payload['text'] } });
+ },
+ document: { activeElement: null },
+ elements: {
+ device: { style: { setProperty() {} } },
+ htmlPreviewRoot: {
+ classList: { toggle() {} },
+ contains: () => true,
+ replaceChildren() {},
+ },
+ },
+ hasSnapshotTree: () => hasTree,
+ loadRealSnapshot: () => Promise.resolve(),
+ newInput,
+ newScroll,
+ nodes,
+ oldInput,
+ oldScroll,
+ runAllTimers,
+ setHasTree: (value: boolean) => {
+ hasTree = value;
+ },
+ state,
+ target,
+ window: {
+ clearTimeout: (timerId: number) => timers.delete(timerId),
+ setTimeout: (callback: () => void, delayMs: number) => {
+ const timerId = nextTimerId;
+ nextTimerId += 1;
+ timers.set(timerId, { callback, dueAt: currentTime + delayMs });
+ return timerId;
+ },
+ },
+ });
+ new vm.Script(
+ `${treeModelSource}\n${modelSource}
+ findNode = nodeId => nodes[nodeId] || null;
+ findNodeAtPoint = (_point, predicate) => predicate(activeScrollNode.current) ? activeScrollNode.current : null;
+ getElementIdForNode = node => node?.elementId ?? null;
+ getNodeAttributes = node => node.attributes;
+ pointFromScreenEvent = () => ({ x: 10, y: 20 });
+ selectPreviewNode = () => {};
+ ${previewSource}
+ associateHtmlPreviewElement(oldInput, target);
+ associateHtmlPreviewElement(oldScroll, target);`,
+ { filename: 'debugger-input-bundle.js' },
+ ).runInContext(vmContext);
+
+ new vm.Script(
+ `enqueueDebuggerInput(target, { type: 'focus', elementId: 99, focused: true }, { quiet: true, refresh: false });`,
+ ).runInContext(vmContext);
+ await settlePromises();
+ expect(requests).toEqual([jasmine.objectContaining({ elementId: 99, type: 'focus' })]);
+
+ new vm.Script(
+ `dispatchHtmlPreviewTextInput({ target: oldInput });
+ dispatchHtmlPreviewScrollInput({
+ target: oldScroll,
+ deltaX: 1,
+ deltaY: 2,
+ preventDefault() {},
+ stopPropagation() {},
+ });`,
+ ).runInContext(vmContext);
+ runAllTimers();
+ await settlePromises();
+ expect(requests.length).toBe(1);
+
+ new vm.Script(
+ `setHasTree(false);
+ renderHtmlPreview();
+ associateHtmlPreviewElement(newInput, target);
+ associateHtmlPreviewElement(newScroll, target);
+ activeScrollNode.current = nodes['new-scroll'];
+ setHasTree(true);
+ dispatchHtmlPreviewTextInput({ target: newInput });
+ dispatchHtmlPreviewScrollInput({
+ target: newScroll,
+ deltaX: 3,
+ deltaY: 4,
+ preventDefault() {},
+ stopPropagation() {},
+ });`,
+ ).runInContext(vmContext);
+ runAllTimers();
+ firstResponse.resolve({ input: { handled: true } });
+ await (new vm.Script('debuggerInputDispatchTail').runInContext(vmContext) as Promise);
+ await settlePromises();
+
+ expect(requests).toEqual([
+ jasmine.objectContaining({ elementId: 99, type: 'focus' }),
+ jasmine.objectContaining({ elementId: 42, text: 'new', type: 'text' }),
+ jasmine.objectContaining({ deltaX: 3, deltaY: 4, elementId: 43, type: 'scroll' }),
+ ]);
+ expect(new vm.Script('htmlPreviewQueuedInputs.size').runInContext(vmContext)).toBe(0);
+ });
+
+ 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 {
+ attributes: Record;
+ elementId: number;
+ id: string;
+ tag: string;
+ }
+ interface FakeTimer {
+ callback: () => void;
+ dueAt: number;
+ }
+ const requests: Array<{
+ params: { port: number; clientId: string; contextId: string };
+ payload: Record;
+ }> = [];
+ const timers = new Map();
+ let currentTime = 0;
+ let nextTimerId = 1;
+ const runNextTimer = (): void => {
+ const nextTimer = Array.from(timers.entries()).sort(
+ ([firstId, first], [secondId, second]) => first.dueAt - second.dueAt || firstId - secondId,
+ )[0];
+ if (!nextTimer) throw new Error('Expected a scheduled timer.');
+ const [timerId, timer] = nextTimer;
+ timers.delete(timerId);
+ currentTime = timer.dueAt;
+ timer.callback();
+ };
+ const settlePromises = async (): Promise => {
+ for (let index = 0; index < 12; index += 1) await Promise.resolve();
+ };
+ class FakePreviewElement {
+ readonly dataset: { previewElementId: string; previewNodeId: string };
+
+ constructor(nodeId: string, elementId: number) {
+ this.dataset = { previewElementId: String(elementId), previewNodeId: nodeId };
+ }
+
+ closest(): FakePreviewElement {
+ return this;
+ }
+ }
+ class FakeInputElement extends FakePreviewElement {
+ readonly isContentEditable = false;
+ readonly selectionEnd = 5;
+ readonly selectionStart = 5;
+ readonly value = 'draft';
+
+ blur(): void {}
+ }
+ class FakeTextAreaElement extends FakePreviewElement {}
+ const editableElement = new FakeInputElement('input', 42);
+ const scrollElementA = new FakePreviewElement('scroll-a', 43);
+ const scrollElementB = new FakePreviewElement('scroll-b', 44);
+ const tapElementB = new FakePreviewElement('tap-b', 45);
+ const inputNode: FakeNode = { id: 'input', elementId: 42, tag: 'textfield', attributes: {} };
+ const scrollNodeA: FakeNode = { id: 'scroll-a', elementId: 43, tag: 'scroll', attributes: {} };
+ const scrollNodeB: FakeNode = { id: 'scroll-b', elementId: 44, tag: 'scroll', attributes: {} };
+ const tapNodeB: FakeNode = { id: 'tap-b', elementId: 45, tag: 'view', attributes: {} };
+ const nodes: Record = {
+ input: inputNode,
+ 'scroll-a': scrollNodeA,
+ 'scroll-b': scrollNodeB,
+ 'tap-b': tapNodeB,
+ };
+ const activeScrollNode = { current: scrollNodeA };
+ const targetA = Object.freeze({ port: 13_591, clientId: 'client-a', contextId: 'context-a' });
+ const targetB = Object.freeze({ port: 13_592, clientId: 'client-b', contextId: 'context-b' });
+ const state = {
+ source: 'daemon',
+ inputRefreshTimers: new Map(),
+ snapshot: { target: { proxyPort: targetA.port, clientId: targetA.clientId, contextId: targetA.contextId } },
+ };
+ const context = {
+ HTMLInputElement: FakeInputElement,
+ HTMLTextAreaElement: FakeTextAreaElement,
+ activeScrollNode,
+ addLog: () => {},
+ apiPost: (
+ _path: string,
+ params: { port: number; clientId: string; contextId: string },
+ payload: Record,
+ ) => {
+ requests.push({ params, payload });
+ return Promise.resolve({ input: { handled: true } });
+ },
+ document: { activeElement: null },
+ editableElement,
+ elements: { htmlPreviewRoot: { contains: () => true } },
+ hasSnapshotTree: () => true,
+ loadRealSnapshot: () => Promise.resolve(),
+ nodes,
+ scrollElementA,
+ scrollElementB,
+ state,
+ tapElementB,
+ targetA,
+ targetB,
+ testFindNode: (nodeId: string) => nodes[nodeId] || null,
+ testFindNodeAtPoint: (_point: unknown, predicate: (candidate: FakeNode) => boolean) =>
+ predicate(activeScrollNode.current) ? activeScrollNode.current : null,
+ window: {
+ clearTimeout: (timerId: number) => timers.delete(timerId),
+ setTimeout: (callback: () => void, delayMs: number) => {
+ const timerId = nextTimerId;
+ nextTimerId += 1;
+ timers.set(timerId, { callback, dueAt: currentTime + delayMs });
+ return timerId;
+ },
+ },
+ };
+ const setup = new vm.Script(
+ `${treeModelSource}\n${modelSource}
+ findNode = testFindNode;
+ findNodeAtPoint = testFindNodeAtPoint;
+ getElementIdForNode = node => node?.elementId ?? null;
+ getNodeAttributes = node => node.attributes;
+ pointFromScreenEvent = () => ({ x: 10, y: 20 });
+ selectPreviewNode = () => {};
+ ${previewSource}
+ associateHtmlPreviewElement(editableElement, targetA);
+ associateHtmlPreviewElement(scrollElementA, targetA);
+ associateHtmlPreviewElement(scrollElementB, targetB);
+ associateHtmlPreviewElement(tapElementB, targetB);`,
+ { filename: 'debugger-input-bundle.js' },
+ );
+ const vmContext = vm.createContext(context);
+ setup.runInContext(vmContext);
+
+ activeScrollNode.current = scrollNodeB;
+ new vm.Script(
+ `dispatchHtmlPreviewTextInput({ target: editableElement });
+ dispatchHtmlPreviewScrollInput({
+ target: scrollElementB,
+ deltaX: 2,
+ deltaY: 3,
+ preventDefault() {},
+ stopPropagation() {},
+ });`,
+ ).runInContext(vmContext);
+
+ runNextTimer();
+ await settlePromises();
+ expect(requests).toEqual([]);
+ runNextTimer();
+ await settlePromises();
+ expect(requests.map(request => request.payload['type'])).toEqual(['text', 'scroll']);
+ expect(requests.map(request => request.params.contextId)).toEqual(['context-a', 'context-b']);
+
+ activeScrollNode.current = scrollNodeA;
+ const wheelBeforeTap = new vm.Script(
+ `dispatchHtmlPreviewScrollInput({
+ target: scrollElementA,
+ deltaX: 0,
+ deltaY: 4,
+ preventDefault() {},
+ stopPropagation() {},
+ });
+ dispatchHtmlPreviewTapInput({
+ target: tapElementB,
+ preventDefault() {},
+ stopPropagation() {},
+ });`,
+ ).runInContext(vmContext) as Promise;
+ await settlePromises();
+ expect(requests.map(request => request.payload['type'])).toEqual(['text', 'scroll']);
+ runNextTimer();
+ await wheelBeforeTap;
+ expect(requests.map(request => request.payload['type'])).toEqual(['text', 'scroll', 'scroll', 'tap']);
+
+ const lifecycle = new vm.Script(
+ `(() => {
+ const focus = dispatchHtmlPreviewFocusInput({ target: editableElement }, true);
+ dispatchHtmlPreviewTextInput({ target: editableElement });
+ const blur = dispatchHtmlPreviewFocusInput({ target: editableElement }, false);
+ const tap = dispatchHtmlPreviewTapInput({
+ target: tapElementB,
+ preventDefault() {},
+ stopPropagation() {},
+ });
+ return Promise.all([focus, blur, tap]);
+ })();`,
+ ).runInContext(vmContext) as Promise;
+ await lifecycle;
+ expect(requests.slice(-4).map(request => request.payload['type'])).toEqual(['focus', 'text', 'focus', 'tap']);
+ expect(requests.at(-4)?.payload['focused']).toBeTrue();
+ expect(requests.at(-2)?.payload['focused']).toBeFalse();
+ });
+
+ it('serializes input across elements while preserving each immutable target', async () => {
+ const modelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-model.js'), 'utf8');
+ const firstDispatch = createDeferred<{ input: { handled: boolean } }>();
+ const requests: Array<{
+ params: { port: number; clientId: string; contextId: string };
+ payload: Record;
+ }> = [];
+ const state = {
+ source: 'daemon',
+ inputRefreshTimers: new Map(),
+ snapshot: {
+ target: { proxyPort: 13_591, clientId: 'client-a', contextId: 'context-a' },
+ },
+ };
+ let first = true;
+ const operation = new vm.Script(
+ `${modelSource}
+ (() => {
+ const targetA = captureDebuggerInputTarget();
+ const focus = enqueueDebuggerInput(targetA, { type: 'focus', elementId: 1, focused: true }, { quiet: true, refresh: false });
+ const text = enqueueDebuggerInput(targetA, { type: 'text', elementId: 1, text: 'draft' }, { quiet: true, refresh: false });
+ const blur = enqueueDebuggerInput(targetA, { type: 'focus', elementId: 1, focused: false }, { quiet: true, refresh: false });
+ state.snapshot.target = { proxyPort: 13592, clientId: 'client-b', contextId: 'context-b' };
+ const targetB = captureDebuggerInputTarget();
+ const tap = enqueueDebuggerInput(targetB, { type: 'tap', elementId: 2 }, { quiet: true, refresh: false });
+ return Promise.all([focus, text, blur, tap]);
+ })();`,
+ { filename: 'debugger-model.js' },
+ ).runInNewContext({
+ state,
+ getSelectedTargetParams: () => {
+ const target = state.snapshot.target;
+ return { port: target.proxyPort, clientId: target.clientId, contextId: target.contextId };
+ },
+ apiPost: (
+ _path: string,
+ params: { port: number; clientId: string; contextId: string },
+ payload: Record,
+ ) => {
+ requests.push({ params, payload });
+ if (first) {
+ first = false;
+ return firstDispatch.promise;
+ }
+ return Promise.resolve({ input: { handled: true } });
+ },
+ addLog: () => {},
+ }) as Promise;
+
+ for (let index = 0; index < 6; index += 1) await Promise.resolve();
+ expect(requests.map(request => request.payload['type'])).toEqual(['focus']);
+ firstDispatch.resolve({ input: { handled: true } });
+ await operation;
+
+ expect(requests.map(request => request.payload['type'])).toEqual(['focus', 'text', 'focus', 'tap']);
+ expect(requests.slice(0, 3).map(request => request.params.contextId)).toEqual([
+ 'context-a',
+ 'context-a',
+ 'context-a',
+ ]);
+ expect(requests[3]?.params.contextId).toBe('context-b');
+ expect(Object.isFrozen(requests[0]?.params)).toBeTrue();
+ expect(Object.isFrozen(requests[3]?.params)).toBeTrue();
+ });
+
+ it('uses unambiguous debugger input queue keys', () => {
+ const modelSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-model.js'), 'utf8');
+ const keys = new vm.Script(
+ `${modelSource}
+ [
+ debuggerInputTargetKey({ port: 1, clientId: 'a:b', contextId: 'c' }),
+ debuggerInputTargetKey({ port: 1, clientId: 'a', contextId: 'b:c' }),
+ debuggerInputTargetElementKey({ port: 1, clientId: 'a:b', contextId: 'c' }, 2),
+ debuggerInputTargetElementKey({ port: 1, clientId: 'a', contextId: 'b:c' }, 2),
+ ];`,
+ { filename: 'debugger-model.js' },
+ ).runInNewContext({}) as string[];
+
+ expect(keys[0]).not.toBe(keys[1]);
+ expect(keys[2]).not.toBe(keys[3]);
+ });
+
it('recovers an active CPU profile from the contexts response', async () => {
const performanceSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-performance.js'), 'utf8');
const bootstrapSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-bootstrap.js'), 'utf8');
const performanceState = {
+ traceActive: false,
+ traceCapturePending: false,
+ traceResultPending: false,
+ activeTraceTarget: null,
+ traceSupported: true,
+ lastTrace: null,
profileActive: false,
activeProfileContextId: null as string | null,
lastProfile: null,
@@ -125,6 +1124,15 @@ describe('npm package contents', () => {
const context = {
state: { performance: performanceState },
elements: {
+ traceStatusPill: { textContent: '', className: '' },
+ traceStartButton: { disabled: false },
+ traceStopButton: { disabled: true },
+ traceCaptureButton: { disabled: false, textContent: '' },
+ traceExportButton: { disabled: true },
+ rendererTracingToggle: { checked: true, disabled: false },
+ traceDurationInput: { value: '5', disabled: false },
+ traceSummary: { innerHTML: '' },
+ traceEvents: { innerHTML: '' },
profileStatusPill: { textContent: '', className: '' },
profileStartButton,
profileStopButton,
@@ -147,6 +1155,7 @@ describe('npm package contents', () => {
}),
addLog: () => {},
escapeHtml: String,
+ hasSelectedLiveTarget: () => false,
};
await (new vm.Script(`${performanceSource}\nrefreshProfileContexts({ silent: true });`, {
@@ -170,6 +1179,12 @@ describe('npm package contents', () => {
};
const pendingCapture = createDeferred();
const performanceState = {
+ traceActive: false,
+ traceCapturePending: false,
+ traceResultPending: false,
+ activeTraceTarget: null,
+ traceSupported: true,
+ lastTrace: null,
profileActive: false,
activeProfileContextId: null as string | null,
lastProfile: null as typeof completedProfile | null,
@@ -187,6 +1202,15 @@ describe('npm package contents', () => {
const context = {
state: { performance: performanceState },
elements: {
+ traceStatusPill: { textContent: '', className: '' },
+ traceStartButton: { disabled: false },
+ traceStopButton: { disabled: true },
+ traceCaptureButton: { disabled: false, textContent: '' },
+ traceExportButton: { disabled: true },
+ rendererTracingToggle: { checked: true, disabled: false },
+ traceDurationInput: { value: '5', disabled: false },
+ traceSummary: { innerHTML: '' },
+ traceEvents: { innerHTML: '' },
profileStatusPill,
profileStartButton,
profileStopButton,
@@ -199,6 +1223,7 @@ describe('npm package contents', () => {
apiPost: () => pendingCapture.promise,
addLog: () => {},
escapeHtml: String,
+ hasSelectedLiveTarget: () => false,
};
const captureOperation = new vm.Script(`${performanceSource}\ncaptureCpuProfile();`, {
@@ -225,4 +1250,506 @@ describe('npm package contents', () => {
expect(profileStopButton.disabled).toBeTrue();
expect(profileCaptureButton.disabled).toBeFalse();
});
+
+ it('marks one-shot renderer capture pending until the measurement completes', async () => {
+ const performanceSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-performance.js'), 'utf8');
+ const completedTrace = {
+ recording: false,
+ completedRecordingAvailable: false,
+ tracingSupported: true,
+ captureTarget: { port: 13_591, clientId: '1', contextId: 'root' },
+ traces: [],
+ traceCount: 0,
+ perfetto: { traceEvents: [] },
+ };
+ const pendingCapture = createDeferred();
+ const performanceState = {
+ traceActive: false,
+ traceCapturePending: false,
+ traceResultPending: false,
+ traceStateUnknown: false,
+ activeTraceTarget: null as { port: number; clientId: string; contextId: string } | null,
+ traceSupported: true,
+ lastTrace: null as typeof completedTrace | null,
+ profileActive: false,
+ activeProfileContextId: null,
+ lastProfile: null,
+ profileContexts: [],
+ };
+ const traceStatusPill = { textContent: '', className: '' };
+ const traceStartButton = { disabled: false };
+ const traceStopButton = { disabled: true };
+ const traceCaptureButton = { disabled: false, textContent: '' };
+ const rendererTracingToggle = { checked: true, disabled: false };
+ const traceDurationInput = { value: '5', disabled: false };
+ const context = {
+ state: { performance: performanceState },
+ elements: {
+ traceStatusPill,
+ traceStartButton,
+ traceStopButton,
+ traceCaptureButton,
+ traceExportButton: { disabled: true },
+ rendererTracingToggle,
+ traceDurationInput,
+ traceSummary: { innerHTML: '' },
+ traceEvents: { innerHTML: '' },
+ profileStatusPill: { textContent: '', className: '' },
+ profileStartButton: { disabled: false },
+ profileStopButton: { disabled: true },
+ profileCaptureButton: { disabled: false },
+ profileExportButton: { disabled: true },
+ profileSummary: { innerHTML: '' },
+ profileContextSelect: { value: '', innerHTML: '', disabled: false },
+ },
+ apiGet: () => Promise.resolve({ recording: false, completedRecordingAvailable: false, tracingSupported: true }),
+ apiPost: () => pendingCapture.promise,
+ addLog: () => {},
+ escapeHtml: String,
+ getSelectedTargetParams: () => ({ port: 13_591, clientId: '1', contextId: 'root' }),
+ hasSelectedLiveTarget: () => true,
+ };
+
+ const captureOperation = new vm.Script(`${performanceSource}\ncapturePerformanceTrace();`, {
+ filename: 'debugger-performance.js',
+ }).runInNewContext(context) as Promise;
+ await new Promise(resolve => setImmediate(resolve));
+
+ expect(performanceState.traceCapturePending).toBeTrue();
+ expect(performanceState.traceStateUnknown).toBeTrue();
+ expect(performanceState.activeTraceTarget).toEqual({ port: 13_591, clientId: '1', contextId: 'root' });
+ expect(traceStatusPill.textContent).toBe('Capturing');
+ expect(traceStartButton.disabled).toBeTrue();
+ expect(traceStopButton.disabled).toBeFalse();
+ expect(traceCaptureButton.disabled).toBeTrue();
+ expect(rendererTracingToggle.disabled).toBeTrue();
+ expect(traceDurationInput.disabled).toBeTrue();
+
+ pendingCapture.resolve(completedTrace);
+ await captureOperation;
+
+ expect(performanceState.traceCapturePending).toBeFalse();
+ expect(performanceState.traceStateUnknown).toBeFalse();
+ expect(performanceState.activeTraceTarget).toBeNull();
+ expect(performanceState.lastTrace).toBe(completedTrace);
+ expect(traceStatusPill.textContent).toBe('Idle');
+ });
+
+ it('keeps restored trace uncertainty after failed recovery and clears it only after verified absence', async () => {
+ const performanceSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-performance.js'), 'utf8');
+ const oldTarget = { port: 13_591, clientId: '1', contextId: 'root' };
+ const performanceState = {
+ traceActive: false,
+ traceCapturePending: false,
+ traceResultPending: false,
+ traceStateUnknown: true,
+ activeTraceTarget: oldTarget,
+ traceSupported: true,
+ lastTrace: null,
+ };
+ let targetPresence = 'transient';
+ const context = {
+ state: { performance: performanceState },
+ apiPost: () => Promise.reject(new Error('temporary stop transport failure')),
+ apiGet: (url: string) => {
+ if (url === '/api/performance/trace/status') {
+ return Promise.reject(new Error('temporary status transport failure'));
+ }
+ if (targetPresence === 'transient') {
+ return Promise.reject(new Error('temporary daemon status failure'));
+ }
+ return Promise.resolve({
+ ports: [
+ {
+ port: oldTarget.port,
+ connected: targetPresence !== 'disconnected',
+ clients: [
+ {
+ client_id: oldTarget.clientId,
+ contexts: targetPresence === 'present' ? [{ id: oldTarget.contextId }] : [],
+ contextError: null,
+ },
+ ],
+ },
+ ],
+ });
+ },
+ addLog: () => {},
+ getSelectedTargetParams: () => oldTarget,
+ renderPerformance: () => {},
+ };
+ await (new vm.Script(`${performanceSource}\nrenderPerformance = () => {};\nrecoverPerformanceTraceState();`, {
+ filename: 'debugger-performance.js',
+ }).runInNewContext(context) as Promise);
+
+ expect(performanceState.traceStateUnknown).toBeTrue();
+ expect(performanceState.activeTraceTarget).toEqual(oldTarget);
+
+ const script = new vm.Script(
+ `${performanceSource}\npreparePerformanceTraceTargetSwitch({ port: 13592, clientId: '2', contextId: 'next' });`,
+ { filename: 'debugger-performance.js' },
+ );
+
+ const transientResult = await (script.runInNewContext(context) as Promise);
+
+ expect(transientResult).toBeFalse();
+ expect(performanceState.traceStateUnknown).toBeTrue();
+ expect(performanceState.activeTraceTarget).toEqual(oldTarget);
+
+ targetPresence = 'disconnected';
+ const transientDisconnectedResult = await (script.runInNewContext(context) as Promise);
+
+ expect(transientDisconnectedResult).toBeFalse();
+ expect(performanceState.traceStateUnknown).toBeTrue();
+ expect(performanceState.activeTraceTarget).toEqual(oldTarget);
+
+ targetPresence = 'present';
+ const stillPresentResult = await (script.runInNewContext(context) as Promise);
+
+ expect(stillPresentResult).toBeFalse();
+ expect(performanceState.traceStateUnknown).toBeTrue();
+ expect(performanceState.activeTraceTarget).toEqual(oldTarget);
+
+ targetPresence = 'missing';
+ const disconnectedResult = await (script.runInNewContext(context) as Promise);
+
+ expect(disconnectedResult).toBeTrue();
+ expect(performanceState.traceActive).toBeFalse();
+ expect(performanceState.traceResultPending).toBeFalse();
+ expect(performanceState.traceStateUnknown).toBeFalse();
+ expect(performanceState.activeTraceTarget).toBeNull();
+ });
+
+ it('restores a persisted active trace target conservatively as unknown', () => {
+ const sessionSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-session.js'), 'utf8');
+ const savedTarget = { port: 13_591, clientId: '1', contextId: 'root' };
+ const persistedStates = [
+ { activeTraceTarget: savedTarget },
+ { target: savedTarget, traceActive: true },
+ { target: savedTarget, traceResultPending: true },
+ ];
+
+ for (const persistedState of persistedStates) {
+ const performanceState = {
+ traceActive: false,
+ traceResultPending: false,
+ traceStateUnknown: false,
+ activeTraceTarget: null as typeof savedTarget | null,
+ };
+ const context = {
+ state: {
+ activeSection: 'performance',
+ activeTab: 'overview',
+ overlayMode: 'live',
+ autoRefresh: true,
+ followLatestTarget: true,
+ manualDetach: false,
+ selectedNodeId: null,
+ expandedNodeIds: new Set(),
+ snapshot: { target: {}, targets: [] },
+ performance: performanceState,
+ },
+ elements: {
+ sectionPanels: [],
+ portSelect: { value: '' },
+ treeSearch: { value: '' },
+ logSearch: { value: '' },
+ rendererTracingToggle: { checked: true },
+ traceDurationInput: { value: '5' },
+ profileDurationInput: { value: '5' },
+ profileContextSelect: { value: '' },
+ },
+ window: {
+ sessionStorage: {
+ getItem: () => JSON.stringify(persistedState),
+ setItem: () => {},
+ },
+ },
+ selectedDaemonPort: () => 13_591,
+ normalizeSection: String,
+ console: { warn: () => {} },
+ };
+
+ const restored = new vm.Script(`${sessionSource}\nrestoreDebuggerSessionState();`, {
+ filename: 'debugger-session.js',
+ }).runInNewContext(context) as boolean;
+
+ expect(restored).toBeTrue();
+ expect(performanceState.activeTraceTarget).toEqual(savedTarget);
+ expect(performanceState.traceActive).toBeFalse();
+ expect(performanceState.traceResultPending).toBeFalse();
+ expect(performanceState.traceStateUnknown).toBeTrue();
+ }
+ });
+
+ it('retrieves a retained trace after reload and logs recovery only after Stop succeeds', async () => {
+ const performanceSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-performance.js'), 'utf8');
+ const target = { port: 13_591, clientId: '1', contextId: 'root' };
+ const retainedResult = {
+ recording: false,
+ completedRecordingAvailable: false,
+ tracingSupported: true,
+ traces: [],
+ traceCount: 0,
+ perfettoMetadata: {},
+ };
+ const performanceState = {
+ traceActive: false,
+ traceCapturePending: false,
+ traceResultPending: false,
+ traceStateUnknown: true,
+ activeTraceTarget: target,
+ traceSupported: true,
+ lastTrace: null as typeof retainedResult | null,
+ };
+ let stopSucceeds = false;
+ const logMessages: string[] = [];
+ const context = {
+ state: { performance: performanceState },
+ apiGet: () => Promise.resolve({ recording: false, completedRecordingAvailable: false, tracingSupported: true }),
+ apiPost: () =>
+ stopSucceeds ? Promise.resolve(retainedResult) : Promise.reject(new Error('temporary Stop failure')),
+ addLog: (_level: string, _source: string, message: string) => logMessages.push(message),
+ getSelectedTargetParams: () => target,
+ renderPerformance: () => {},
+ };
+ new vm.Script(`${performanceSource}\nrenderPerformance = () => {};`, {
+ filename: 'debugger-performance.js',
+ }).runInNewContext(context);
+
+ await (new vm.Script('recoverPerformanceTraceState();').runInNewContext(context) as Promise);
+
+ expect(performanceState.traceStateUnknown).toBeTrue();
+ expect(performanceState.activeTraceTarget).toEqual(target);
+ expect(logMessages).not.toContain('Recovered the completed process-wide renderer trace from the runtime.');
+
+ stopSucceeds = true;
+ await (new vm.Script('recoverPerformanceTraceState();').runInNewContext(context) as Promise);
+
+ expect(performanceState.traceStateUnknown).toBeFalse();
+ expect(performanceState.activeTraceTarget).toBeNull();
+ expect(performanceState.lastTrace).toBe(retainedResult);
+ expect(logMessages).toContain('Recovered the completed process-wide renderer trace from the runtime.');
+ expect(
+ await (new vm.Script(
+ "preparePerformanceTraceTargetSwitch({ port: 13592, clientId: '2', contextId: 'next' });",
+ ).runInNewContext(context) as Promise),
+ ).toBeTrue();
+ });
+
+ it('keeps a lost Start response explicitly unknown when status recovery also fails', async () => {
+ const performanceSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-performance.js'), 'utf8');
+ const target = { port: 13_591, clientId: '1', contextId: 'root' };
+ const performanceState = {
+ traceActive: false,
+ traceCapturePending: false,
+ traceResultPending: false,
+ traceStateUnknown: false,
+ activeTraceTarget: null as typeof target | null,
+ traceSupported: true,
+ lastTrace: null,
+ };
+ let statusCount = 0;
+ let persistCount = 0;
+ const context = {
+ state: { performance: performanceState },
+ elements: {
+ rendererTracingToggle: { checked: true },
+ },
+ apiGet: () => {
+ statusCount++;
+ return statusCount === 1
+ ? Promise.resolve({ recording: false, completedRecordingAvailable: false, tracingSupported: true })
+ : Promise.reject(new Error('status unavailable'));
+ },
+ apiPost: () => Promise.reject(new Error('lost start response')),
+ addLog: () => {},
+ getSelectedTargetParams: () => target,
+ persistDebuggerSessionState: () => {
+ persistCount++;
+ },
+ renderPerformance: () => {},
+ };
+
+ await (new vm.Script(`${performanceSource}\nrenderPerformance = () => {};\nstartPerformanceTrace();`, {
+ filename: 'debugger-performance.js',
+ }).runInNewContext(context) as Promise);
+
+ expect(performanceState.traceStateUnknown).toBeTrue();
+ expect(performanceState.activeTraceTarget).toEqual(target);
+ expect(persistCount).toBeGreaterThan(0);
+ });
+
+ it('replays Stop after a lost response and retrieves the retained result', async () => {
+ const performanceSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-performance.js'), 'utf8');
+ const target = { port: 13_591, clientId: '1', contextId: 'root' };
+ const retainedResult = {
+ recording: false,
+ completedRecordingAvailable: false,
+ tracingSupported: true,
+ traces: [],
+ traceCount: 0,
+ perfettoMetadata: {},
+ };
+ const performanceState = {
+ traceActive: true,
+ traceCapturePending: false,
+ traceResultPending: false,
+ traceStateUnknown: false,
+ activeTraceTarget: target,
+ traceSupported: true,
+ lastTrace: null as typeof retainedResult | null,
+ };
+ let postCount = 0;
+ const context = {
+ state: { performance: performanceState },
+ apiGet: () => Promise.resolve({ recording: false, completedRecordingAvailable: false, tracingSupported: true }),
+ apiPost: () => {
+ postCount++;
+ return postCount === 1 ? Promise.reject(new Error('lost stop response')) : Promise.resolve(retainedResult);
+ },
+ addLog: () => {},
+ getSelectedTargetParams: () => target,
+ renderPerformance: () => {},
+ };
+
+ const stopped = await (new vm.Script(
+ `${performanceSource}\nrenderPerformance = () => {};\nstopPerformanceTrace({});`,
+ {
+ filename: 'debugger-performance.js',
+ },
+ ).runInNewContext(context) as Promise);
+
+ expect(stopped).toBeTrue();
+ expect(postCount).toBe(2);
+ expect(performanceState.lastTrace).toBe(retainedResult);
+ expect(performanceState.traceStateUnknown).toBeFalse();
+ expect(performanceState.activeTraceTarget).toBeNull();
+ });
+
+ it('blocks Start and Capture when status is unavailable or lacks explicit support', async () => {
+ const performanceSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-performance.js'), 'utf8');
+ const target = { port: 13_591, clientId: '1', contextId: 'root' };
+ const performanceState = {
+ traceActive: false,
+ traceCapturePending: false,
+ traceResultPending: false,
+ traceStateUnknown: false,
+ activeTraceTarget: null,
+ traceSupported: true,
+ lastTrace: null,
+ };
+ let statusMode = 'unavailable';
+ let postCount = 0;
+ const context = {
+ state: { performance: performanceState },
+ elements: {
+ rendererTracingToggle: { checked: true },
+ traceDurationInput: { value: '1' },
+ },
+ apiGet: () =>
+ statusMode === 'unavailable'
+ ? Promise.reject(new Error('status timeout'))
+ : Promise.resolve({ recording: false, completedRecordingAvailable: false }),
+ apiPost: () => {
+ postCount++;
+ return Promise.resolve({});
+ },
+ addLog: () => {},
+ getSelectedTargetParams: () => target,
+ renderPerformance: () => {},
+ };
+ const startScript = new vm.Script(`${performanceSource}\nrenderPerformance = () => {};\nstartPerformanceTrace();`, {
+ filename: 'debugger-performance.js',
+ });
+ const captureScript = new vm.Script(
+ `${performanceSource}\nrenderPerformance = () => {};\ncapturePerformanceTrace();`,
+ {
+ filename: 'debugger-performance.js',
+ },
+ );
+
+ await (startScript.runInNewContext(context) as Promise);
+ await (captureScript.runInNewContext(context) as Promise);
+ statusMode = 'legacy';
+ await (startScript.runInNewContext(context) as Promise);
+ await (captureScript.runInNewContext(context) as Promise);
+
+ expect(postCount).toBe(0);
+ });
+
+ it('does not start unsupported traces while keeping active cleanup enabled', async () => {
+ const performanceSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-performance.js'), 'utf8');
+ const target = { port: 13_591, clientId: '1', contextId: 'root' };
+ const performanceState = {
+ traceActive: false,
+ traceCapturePending: false,
+ traceResultPending: false,
+ traceStateUnknown: false,
+ activeTraceTarget: null as typeof target | null,
+ traceSupported: true,
+ lastTrace: null,
+ profileActive: false,
+ activeProfileContextId: null,
+ lastProfile: null,
+ profileContexts: [],
+ };
+ const traceStartButton = { disabled: false };
+ const traceStopButton = { disabled: true };
+ const traceCaptureButton = { disabled: false, textContent: '' };
+ let postCount = 0;
+ const context = {
+ state: { performance: performanceState },
+ elements: {
+ traceStatusPill: { textContent: '', className: '' },
+ traceStartButton,
+ traceStopButton,
+ traceCaptureButton,
+ traceExportButton: { disabled: true },
+ rendererTracingToggle: { checked: true, disabled: false },
+ traceDurationInput: { value: '5', disabled: false },
+ traceSummary: { innerHTML: '' },
+ traceEvents: { innerHTML: '' },
+ profileStatusPill: { textContent: '', className: '' },
+ profileStartButton: { disabled: false },
+ profileStopButton: { disabled: true },
+ profileCaptureButton: { disabled: false },
+ profileExportButton: { disabled: true },
+ profileSummary: { innerHTML: '' },
+ profileContextSelect: { value: '', innerHTML: '', disabled: false },
+ },
+ apiGet: () =>
+ Promise.resolve({
+ recording: false,
+ completedRecordingAvailable: false,
+ tracingSupported: false,
+ }),
+ apiPost: () => {
+ postCount++;
+ return Promise.resolve({});
+ },
+ addLog: () => {},
+ escapeHtml: String,
+ getSelectedTargetParams: () => target,
+ hasSelectedLiveTarget: () => true,
+ };
+
+ await (new vm.Script(`${performanceSource}\nstartPerformanceTrace();`, {
+ filename: 'debugger-performance.js',
+ }).runInNewContext(context) as Promise);
+ await (new vm.Script(`${performanceSource}\ncapturePerformanceTrace();`, {
+ filename: 'debugger-performance.js',
+ }).runInNewContext(context) as Promise);
+
+ expect(postCount).toBe(0);
+ expect(performanceState.traceSupported).toBeFalse();
+ performanceState.traceActive = true;
+ performanceState.activeTraceTarget = target;
+ new vm.Script(`${performanceSource}\nrenderPerformance();`, {
+ filename: 'debugger-performance.js',
+ }).runInNewContext(context);
+ expect(traceStartButton.disabled).toBeTrue();
+ expect(traceCaptureButton.disabled).toBeTrue();
+ expect(traceStopButton.disabled).toBeFalse();
+ });
});
diff --git a/npm_modules/cli/src/debugger/browserTools.spec.ts b/npm_modules/cli/src/debugger/browserTools.spec.ts
new file mode 100644
index 000000000..73dfda618
--- /dev/null
+++ b/npm_modules/cli/src/debugger/browserTools.spec.ts
@@ -0,0 +1,316 @@
+import 'jasmine';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as vm from 'node:vm';
+
+interface PendingRequest {
+ path: string;
+ resolve: (value: unknown) => void;
+}
+
+interface BrowserHarness {
+ context: Record;
+ pending: PendingRequest[];
+ selectTarget: (target: Record) => void;
+}
+
+const noop = (): void => {};
+
+function target(port: number, clientId: string, contextId: string): Record {
+ return { clientId, contextId, port, proxyPort: port };
+}
+
+function handled(targetValue: Record, data: Record): Record {
+ return { data, handled: true, status: 'handled', target: targetValue };
+}
+
+function createHarness(): BrowserHarness {
+ let selectedTarget = target(13_591, 'A', 'context-A');
+ const pending: PendingRequest[] = [];
+ const classList = { toggle: noop };
+ const element = (): Record => ({ classList, disabled: false, innerHTML: '', textContent: '' });
+ const state = {
+ attached: true,
+ providers: {
+ activeTab: 'storage',
+ error: null,
+ generation: 0,
+ loading: false,
+ registry: null,
+ selectedDatabaseId: null,
+ selectedSqlProviderId: null,
+ selectedStorageProviderId: null,
+ selectedTable: null,
+ sql: null,
+ sqlLimit: 50,
+ sqlLoading: false,
+ sqlOffset: 0,
+ sqlTable: null,
+ storage: null,
+ targetKey: null,
+ },
+ settings: {
+ error: null,
+ generation: 0,
+ loading: false,
+ selectedGroupId: null,
+ snapshot: null,
+ targetKey: null,
+ },
+ };
+ const context = vm.createContext({
+ addLog: noop,
+ apiGet: (requestPath: string): Promise =>
+ new Promise(resolve => pending.push({ path: requestPath, resolve })),
+ apiPost: (): Promise => Promise.reject(new Error('Unexpected provider request')),
+ document: {
+ querySelectorAll: (): unknown[] => [],
+ },
+ elements: {
+ providerContent: element(),
+ providerRefreshButton: element(),
+ providerStatusPill: element(),
+ settingsContent: element(),
+ settingsGroupSelect: element(),
+ settingsRefreshButton: element(),
+ settingsStatusPill: element(),
+ },
+ escapeHtml: String,
+ getSelectedTargetParams: (): Record => selectedTarget,
+ hasSelectedLiveTarget: (): boolean => true,
+ requestDebuggerAction: noop,
+ state,
+ }) as Record;
+ const debuggerRoot = path.resolve(process.cwd(), 'debugger');
+ vm.runInContext(fs.readFileSync(path.join(debuggerRoot, 'debugger-providers.js'), 'utf8'), context);
+ vm.runInContext(fs.readFileSync(path.join(debuggerRoot, 'debugger-settings.js'), 'utf8'), context);
+ return {
+ context,
+ pending,
+ selectTarget: next => {
+ selectedTarget = next;
+ },
+ };
+}
+
+function call(context: Record, name: string, ...args: unknown[]): T {
+ return (context[name] as (...values: unknown[]) => T)(...args);
+}
+
+describe('debugger browser provider and settings tools', () => {
+ it('discards delayed provider and settings responses from target A after selecting target B', async () => {
+ const harness = createHarness();
+ const targetA = target(13_591, 'A', 'context-A');
+ const targetB = target(13_592, 'B', 'context-B');
+
+ const providerA = call>(harness.context, 'refreshDebuggerProviders', { silent: true });
+ expect(harness.pending[0]?.path).toBe('/api/debugger/providers');
+ harness.selectTarget(targetB);
+ call(harness.context, 'resetDebuggerToolsForTarget');
+ const providerB = call>(harness.context, 'refreshDebuggerProviders', { silent: true });
+ harness.pending[1]?.resolve(handled(targetB, { providers: [], revision: 2 }));
+ await providerB;
+ harness.pending[0]?.resolve(handled(targetA, { providers: [{ id: 'stale' }], revision: 1 }));
+ await providerA;
+
+ const state = harness.context['state'] as { providers: { registry: { revision: number } } };
+ expect(state.providers.registry.revision).toBe(2);
+ expect(
+ call(harness.context, 'applyDebuggerProvidersResult', handled(targetA, { revision: 3 }), targetA),
+ ).toBeFalse();
+
+ const settingsA = call>(harness.context, 'refreshDebugSettings', { silent: true });
+ harness.selectTarget(targetA);
+ call(harness.context, 'resetDebuggerToolsForTarget');
+ const settingsFromA = call>(harness.context, 'refreshDebugSettings', { silent: true });
+ harness.selectTarget(targetB);
+ call(harness.context, 'resetDebuggerToolsForTarget');
+ const settingsFromB = call>(harness.context, 'refreshDebugSettings', { silent: true });
+ const settingsRequests = harness.pending.filter(item => item.path === '/api/debugger/settings');
+ settingsRequests[2]?.resolve(handled(targetB, { groups: [], revision: 20 }));
+ await settingsFromB;
+ settingsRequests[1]?.resolve(handled(targetA, { groups: [], revision: 10 }));
+ await settingsFromA;
+ settingsRequests[0]?.resolve(handled(targetB, { groups: [], revision: 5 }));
+ await settingsA;
+
+ const settingsState = (harness.context['state'] as { settings: { snapshot: { revision: number } } }).settings;
+ expect(settingsState.snapshot.revision).toBe(20);
+ expect(
+ call(harness.context, 'applyDebugSettingsResult', handled(targetA, { revision: 30 }), targetA),
+ ).toBeFalse();
+ });
+
+ it('preserves typed select identities and rejects invalid number input locally', () => {
+ const harness = createHarness();
+ const state = harness.context['state'] as {
+ settings: { selectedGroupId: string; snapshot: Record };
+ };
+ state.settings.selectedGroupId = 'general';
+ state.settings.snapshot = {
+ groups: [
+ {
+ id: 'general',
+ label: 'General',
+ settings: [
+ {
+ id: 'typed',
+ kind: 'select',
+ label: 'Typed',
+ options: [
+ { label: 'number', value: 1 },
+ { label: 'string', value: '1' },
+ { label: 'boolean', value: true },
+ ],
+ value: 1,
+ },
+ { id: 'amount', kind: 'number', label: 'Amount', value: 3 },
+ { id: 'future', kind: 'future-kind', label: 'Future', value: 'x' },
+ ],
+ },
+ ],
+ };
+
+ const readSelect = (value: string): { value: unknown } =>
+ call(harness.context, 'readDebugSettingInput', {
+ dataset: { settingId: 'typed', settingKind: 'select' },
+ value,
+ });
+ expect(readSelect('0').value).toBe(1);
+ expect(readSelect('1').value).toBe('1');
+ expect(readSelect('2').value).toBeTrue();
+ expect(
+ call(harness.context, 'readDebugSettingInput', {
+ dataset: { settingId: 'amount', settingKind: 'number' },
+ value: ' ',
+ }),
+ ).toEqual(jasmine.objectContaining({ error: 'Enter a finite number.' }));
+ expect(
+ call(harness.context, 'readDebugSettingInput', {
+ dataset: { settingId: 'amount', settingKind: 'number' },
+ value: 'Infinity',
+ }),
+ ).toEqual(jasmine.objectContaining({ error: 'Enter a finite number.' }));
+ expect(
+ call(harness.context, 'renderDebugSettingControl', {
+ id: 'future',
+ kind: 'future-kind',
+ label: 'Future',
+ value: 'x',
+ }),
+ ).toContain('Unsupported setting kind: future-kind');
+ });
+
+ it('shows truthful unavailable Storage and SQL surfaces without registered providers', () => {
+ const harness = createHarness();
+ const state = harness.context['state'] as {
+ providers: { activeTab: string; registry: Record };
+ };
+ state.providers.registry = { providers: [] };
+
+ expect(call(harness.context, 'renderStoragePanel')).toContain(
+ 'Storage support is not registered by this target runtime.',
+ );
+ state.providers.activeTab = 'sql';
+ expect(call(harness.context, 'renderSqlPanel')).toContain(
+ 'SQL support is not registered by this target runtime.',
+ );
+ });
+
+ it('selects among multiple providers of the same kind', () => {
+ const harness = createHarness();
+ const state = harness.context['state'] as {
+ providers: {
+ registry: Record;
+ selectedStorageProviderId: string | null;
+ storage: Record | null;
+ };
+ };
+ state.providers.registry = {
+ providers: [
+ { available: true, id: 'storage-a', kind: 'storage', label: 'Storage A' },
+ { available: true, id: 'storage-b', kind: 'storage', label: 'Storage B' },
+ ],
+ };
+
+ expect(call(harness.context, 'selectDebuggerProvider', 'storage', 'storage-b')).toBeTrue();
+ state.providers.storage = { stores: [] };
+
+ expect(state.providers.selectedStorageProviderId).toBe('storage-b');
+ expect(call>(harness.context, 'debuggerProviderForKind', 'storage')['id']).toBe(
+ 'storage-b',
+ );
+ expect(call(harness.context, 'renderStoragePanel')).toContain('id="storageProviderSelect"');
+ expect(call(harness.context, 'renderStoragePanel')).toContain(
+ 'Storage B ',
+ );
+ expect(call(harness.context, 'selectDebuggerProvider', 'storage', 'missing')).toBeFalse();
+ expect(state.providers.selectedStorageProviderId).toBe('storage-b');
+ });
+
+ it('renders bounded PersistentStore diagnostics without losing numeric string encoding', () => {
+ const harness = createHarness();
+ const state = harness.context['state'] as {
+ providers: { registry: Record; storage: Record };
+ };
+ state.providers.registry = {
+ providers: [{ available: true, id: 'persistent-store', kind: 'storage', label: 'PersistentStore' }],
+ };
+ state.providers.storage = {
+ storageError: 'Storage access was denied.',
+ storageInspectionTruncated: true,
+ stores: [
+ {
+ backend: 'memory',
+ entries: [{ encoding: 0, key: 'theme', value: 'dark' }],
+ inspectionTruncated: true,
+ name: 'preferences',
+ },
+ ],
+ };
+
+ const html = call(harness.context, 'renderStoragePanel');
+ expect(html).toContain('Storage access was denied.');
+ expect(html).toContain('Persistent browser-storage discovery was truncated');
+ expect(html).toContain('memory · 1 entry');
+ expect(html).toContain('0');
+ expect(html).toContain('Entry inspection stopped at the debugger scan limit.');
+ expect(html).not.toContain('unknown · 1 entry');
+ });
+
+ it('surfaces provider projection omissions globally and on the affected store', () => {
+ const harness = createHarness();
+ const state = harness.context['state'] as {
+ providers: { registry: Record; storage: Record };
+ };
+ state.providers.registry = {
+ providers: [{ available: true, id: 'persistent-store', kind: 'storage', label: 'PersistentStore' }],
+ };
+ state.providers.storage = {
+ projection: {
+ entriesOmitted: 60,
+ invalidFields: 0,
+ sourceEntries: 100,
+ sourceStores: 2,
+ storesOmitted: 1,
+ truncated: true,
+ truncatedFields: 1,
+ },
+ stores: [
+ {
+ backend: 'memory',
+ entries: [{ encoding: 0, key: 'first', value: 'value' }],
+ entriesTruncated: true,
+ name: 'bounded',
+ },
+ ],
+ };
+
+ const html = call(harness.context, 'renderStoragePanel');
+ expect(html).toContain('Storage transport projection was truncated');
+ expect(html).toContain('1 store omitted');
+ expect(html).toContain('60 entries omitted');
+ expect(html).toContain('1 field truncated');
+ expect(html).toContain('Additional entries were omitted by the debugger snapshot budget.');
+ });
+});
diff --git a/npm_modules/cli/src/debugger/chromiumConsole.spec.ts b/npm_modules/cli/src/debugger/chromiumConsole.spec.ts
new file mode 100644
index 000000000..8a5539dc9
--- /dev/null
+++ b/npm_modules/cli/src/debugger/chromiumConsole.spec.ts
@@ -0,0 +1,230 @@
+import 'jasmine';
+import {
+ ChromiumConsoleLevel,
+ ChromiumConsoleSource,
+ MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH,
+ formatChromiumConsoleEvent,
+} from './chromiumConsole';
+
+const SYNTHETIC_SLACK_BOT_TOKEN = ['xoxb', 'synthetic', 'bot', 'token'].join('-');
+const SYNTHETIC_SLACK_USER_TOKEN = ['xoxp', 'synthetic', 'user', 'token'].join('-');
+
+describe('Chromium DevTools console event formatting', () => {
+ it('preserves console levels, primitive arguments, substitutions, and timestamps', () => {
+ const entry = formatChromiumConsoleEvent({
+ method: 'Runtime.consoleAPICalled',
+ params: {
+ args: [
+ { type: 'string', value: '%cCount: %d (%s)' },
+ { type: 'string', value: 'color: red' },
+ { type: 'number', value: 7.9 },
+ { type: 'string', value: 'ready' },
+ ],
+ timestamp: 1234,
+ type: 'warning',
+ },
+ });
+
+ expect(entry).toEqual({
+ level: ChromiumConsoleLevel.Warning,
+ message: 'Count: 7 (ready)',
+ source: ChromiumConsoleSource.Console,
+ timestamp: 1234,
+ });
+ });
+
+ it('renders bounded object and array previews while redacting sensitive property values', () => {
+ const entry = formatChromiumConsoleEvent({
+ method: 'Runtime.consoleAPICalled',
+ params: {
+ args: [
+ {
+ preview: {
+ properties: [
+ { name: 'screen', type: 'string', value: 'showcase' },
+ { name: 'authorization', type: 'string', value: 'Bearer private-token' },
+ { name: 'accessToken', type: 'string', value: 'private-token' },
+ ],
+ },
+ type: 'object',
+ },
+ {
+ preview: {
+ properties: [
+ { name: '0', type: 'string', value: 'first' },
+ { name: '1', type: 'string', value: 'second' },
+ ],
+ subtype: 'array',
+ },
+ subtype: 'array',
+ type: 'object',
+ },
+ ],
+ type: 'info',
+ },
+ });
+
+ expect(entry?.message).toBe(
+ '{screen: showcase, authorization: [REDACTED], accessToken: [REDACTED]} [first, second]',
+ );
+ expect(entry?.message).not.toContain('private-token');
+ });
+
+ it('redacts headers, credentials, query parameters, and common API keys', () => {
+ const standaloneSecrets = [
+ 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzeW50aGV0aWMifQ.c3ludGhldGljLXNpZ25hdHVyZQ',
+ 'AKIAIOSFODNN7EXAMPLE',
+ 'ghp_abcdefghijklmnopqrstuvwxyz0123456789',
+ 'github_pat_11AA0_synthetic_token_material_1234567890',
+ `AIza${'A'.repeat(35)}`,
+ SYNTHETIC_SLACK_BOT_TOKEN,
+ ];
+ const entry = formatChromiumConsoleEvent({
+ method: 'Log.entryAdded',
+ params: {
+ entry: {
+ level: 'warning',
+ text:
+ 'authorization: Bearer synthetic-token\n' +
+ 'Cookie: session=private-cookie\n' +
+ 'password: correct horse battery staple\n' +
+ '{"access_token":"private-access-token","password":"private-password"}\n' +
+ 'https://example.test/callback?code=private-code&safe=ok\n' +
+ `sk-proj-abcdefghijklmnopqrst\n${standaloneSecrets.join('\n')}`,
+ timestamp: 42,
+ },
+ },
+ });
+
+ expect(entry?.message).toContain('authorization: [REDACTED]');
+ expect(entry?.message).toContain('Cookie: [REDACTED]');
+ expect(entry?.message).toContain('password: [REDACTED]\n');
+ expect(entry?.message).toContain('"access_token":[REDACTED]');
+ expect(entry?.message).toContain('?code=[REDACTED]&safe=ok');
+ expect(entry?.message).not.toContain('private-');
+ expect(entry?.message).not.toContain('correct horse battery staple');
+ expect(entry?.message).not.toContain('abcdefghijklmnopqrst');
+ standaloneSecrets.forEach(secret => expect(entry?.message).not.toContain(secret));
+ });
+
+ it('redacts standalone credentials in formatted runtime arguments', () => {
+ const entry = formatChromiumConsoleEvent({
+ method: 'Runtime.consoleAPICalled',
+ params: {
+ args: [
+ {
+ type: 'string',
+ value: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzeW50aGV0aWMifQ.c3ludGhldGljLXNpZ25hdHVyZQ',
+ },
+ { type: 'string', value: SYNTHETIC_SLACK_USER_TOKEN },
+ ],
+ type: 'log',
+ },
+ });
+
+ expect(entry?.message).toBe('[REDACTED] [REDACTED]');
+ });
+
+ it('preserves valid zero-argument and empty-string log events', () => {
+ const zeroArguments = formatChromiumConsoleEvent({
+ method: 'Runtime.consoleAPICalled',
+ params: { args: [], type: 'log' },
+ });
+ const emptyArgument = formatChromiumConsoleEvent({
+ method: 'Runtime.consoleAPICalled',
+ params: { args: [{ type: 'string', value: '' }], type: 'log' },
+ });
+ const emptyBrowserEntry = formatChromiumConsoleEvent({
+ method: 'Log.entryAdded',
+ params: { entry: { level: 'info', text: '' } },
+ });
+
+ expect(zeroArguments).toEqual(jasmine.objectContaining({ message: '', source: ChromiumConsoleSource.Console }));
+ expect(emptyArgument).toEqual(jasmine.objectContaining({ message: '', source: ChromiumConsoleSource.Console }));
+ expect(emptyBrowserEntry).toEqual(jasmine.objectContaining({ message: '', source: ChromiumConsoleSource.Browser }));
+ });
+
+ it('surfaces runtime exceptions and bounds oversized messages', () => {
+ const exception = formatChromiumConsoleEvent({
+ method: 'Runtime.exceptionThrown',
+ params: {
+ exceptionDetails: {
+ exception: { description: 'Error: Synthetic failure\n at render (showcase.js:4:2)' },
+ text: 'Uncaught',
+ },
+ timestamp: 84,
+ },
+ });
+ const oversized = formatChromiumConsoleEvent({
+ method: 'Runtime.consoleAPICalled',
+ params: {
+ args: [
+ {
+ type: 'string',
+ value: `${'x'.repeat(32_760)} sk-proj-abcdefghijklmnopqrst ${'y'.repeat(100_000)}`,
+ },
+ ],
+ type: 'log',
+ },
+ });
+
+ expect(exception).toEqual({
+ level: ChromiumConsoleLevel.Error,
+ message: 'Error: Synthetic failure\n at render (showcase.js:4:2)',
+ source: ChromiumConsoleSource.Exception,
+ timestamp: 84,
+ });
+ expect(oversized?.message.length).toBe(MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH + 1);
+ expect(oversized?.message.endsWith('…')).toBeTrue();
+ expect(oversized?.message).not.toContain('abcdefghijklmnopqrst');
+ });
+
+ it('does not invoke accessors or recurse through deep and proxy-like remote values', () => {
+ let getterCalls = 0;
+ const remoteObject: Record = { type: 'object', value: {} };
+ let deep = remoteObject['value'] as Record;
+ for (let index = 0; index < 20_000; index += 1) {
+ const next: Record = {};
+ deep['next'] = next;
+ deep = next;
+ }
+ Object.defineProperty(remoteObject, 'description', {
+ enumerable: true,
+ get: () => {
+ getterCalls += 1;
+ throw new Error('must not run');
+ },
+ });
+ const revoked = Proxy.revocable({ type: 'object', description: 'private-value' }, {});
+ revoked.revoke();
+
+ const startedAt = performance.now();
+ const entry = formatChromiumConsoleEvent({
+ method: 'Runtime.consoleAPICalled',
+ params: { args: [remoteObject, revoked.proxy], type: 'debug' },
+ });
+
+ expect(performance.now() - startedAt).toBeLessThan(1000);
+ expect(getterCalls).toBe(0);
+ expect(entry?.message).toBe('object [Unavailable]');
+ expect(entry?.message).not.toContain('private-value');
+ });
+
+ it('bounds sparse argument arrays and ignores malformed or unrelated events', () => {
+ const sparse: unknown[] = [];
+ sparse.length = 10_000_000;
+ sparse[0] = { type: 'string', value: 'first' };
+
+ const entry = formatChromiumConsoleEvent({
+ method: 'Runtime.consoleAPICalled',
+ params: { args: sparse, type: 'log' },
+ });
+
+ expect(entry?.message.length).toBeLessThan(2000);
+ expect(entry?.message.startsWith('first')).toBeTrue();
+ expect(formatChromiumConsoleEvent({ method: 'Network.requestWillBeSent', params: {} })).toBeNull();
+ expect(formatChromiumConsoleEvent({ method: 'Runtime.consoleAPICalled', params: {} })).toBeNull();
+ expect(formatChromiumConsoleEvent({ method: 'Log.entryAdded', params: { entry: 'invalid' } })).toBeNull();
+ expect(formatChromiumConsoleEvent({ method: 'Runtime.exceptionThrown', params: {} })).toBeNull();
+ });
+});
diff --git a/npm_modules/cli/src/debugger/chromiumConsole.ts b/npm_modules/cli/src/debugger/chromiumConsole.ts
new file mode 100644
index 000000000..3783cff9e
--- /dev/null
+++ b/npm_modules/cli/src/debugger/chromiumConsole.ts
@@ -0,0 +1,315 @@
+import type { ChromiumDevToolsEvent } from '../utils/chromiumDevToolsClient';
+
+export const MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH = 16_384;
+const MAX_CONSOLE_ARGUMENTS = 64;
+const MAX_CONSOLE_FRAGMENT_LENGTH = MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH * 2;
+const MAX_PREVIEW_PROPERTIES = 12;
+const MISSING_PROPERTY = Symbol('missing-property');
+const UNAVAILABLE_PROPERTY = Symbol('unavailable-property');
+const SENSITIVE_CONSOLE_KEY =
+ /authorization|cookie|(?:access|refresh|id)[_-]?token|api[_-]?key|password|passwd|credential|secret|private[_-]?input/i;
+const STANDALONE_JWT = /\beyJ[A-Za-z0-9_-]{2,1021}\.[A-Za-z0-9_-]{2,4096}\.[A-Za-z0-9_-]{2,1024}\b/g;
+const STANDALONE_AWS_ACCESS_KEY = /\bA(?:KIA|SIA)[0-9A-Z]{16}\b/g;
+const STANDALONE_GITHUB_TOKEN = /\b(?:gh[pousr]_[A-Za-z0-9]{20,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g;
+const STANDALONE_GOOGLE_API_KEY = /\bAIza[0-9A-Za-z_-]{35}\b/g;
+const STANDALONE_SLACK_TOKEN = /\bxox[a-z]-[0-9A-Za-z-]{10,255}\b/g;
+
+export enum ChromiumConsoleLevel {
+ Debug = 'debug',
+ Error = 'error',
+ Info = 'info',
+ Log = 'log',
+ Warning = 'warn',
+}
+
+export enum ChromiumConsoleSource {
+ Browser = 'browser',
+ Console = 'console',
+ Exception = 'exception',
+}
+
+export interface ChromiumConsoleEntry {
+ level: ChromiumConsoleLevel;
+ message: string;
+ source: ChromiumConsoleSource;
+ timestamp: number;
+}
+
+type OwnDataProperty = unknown | typeof MISSING_PROPERTY | typeof UNAVAILABLE_PROPERTY;
+
+function readOwnDataProperty(value: object, key: PropertyKey): OwnDataProperty {
+ try {
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ if (descriptor === undefined) return MISSING_PROPERTY;
+ return Object.prototype.hasOwnProperty.call(descriptor, 'value') ? descriptor.value : UNAVAILABLE_PROPERTY;
+ } catch {
+ return UNAVAILABLE_PROPERTY;
+ }
+}
+
+function readOwnString(value: object, key: PropertyKey): string | undefined {
+ const property = readOwnDataProperty(value, key);
+ return typeof property === 'string' ? property : undefined;
+}
+
+function safeArrayLength(value: unknown): number | null {
+ try {
+ if (!Array.isArray(value)) return null;
+ } catch {
+ return null;
+ }
+ const length = readOwnDataProperty(value, 'length');
+ return typeof length === 'number' && Number.isSafeInteger(length) && length >= 0 ? length : null;
+}
+
+function boundedFragment(value: string): string {
+ const redacted = redactConsoleText(value);
+ return redacted.length > MAX_CONSOLE_FRAGMENT_LENGTH
+ ? `${redacted.slice(0, MAX_CONSOLE_FRAGMENT_LENGTH)}…`
+ : redacted;
+}
+
+function formatPrimitive(value: unknown): string | null {
+ switch (typeof value) {
+ case 'string': {
+ return boundedFragment(value);
+ }
+ case 'number':
+ case 'bigint':
+ case 'boolean':
+ case 'undefined': {
+ return String(value);
+ }
+ case 'symbol': {
+ return value.description === undefined ? 'Symbol()' : `Symbol(${boundedFragment(value.description)})`;
+ }
+ default: {
+ return value === null ? 'null' : null;
+ }
+ }
+}
+
+function formatPreviewProperty(value: unknown, isArray: boolean): string {
+ if (typeof value !== 'object' || value === null) return '[Unavailable]';
+ const name = readOwnString(value, 'name') ?? '';
+ const rawValue = readOwnString(value, 'value') ?? readOwnString(value, 'type') ?? 'undefined';
+ const formattedValue = SENSITIVE_CONSOLE_KEY.test(name) ? '[REDACTED]' : boundedFragment(rawValue);
+ return isArray ? formattedValue : `${boundedFragment(name)}: ${formattedValue}`;
+}
+
+function formatRemotePreview(remoteObject: object): string {
+ const previewValue = readOwnDataProperty(remoteObject, 'preview');
+ const preview = typeof previewValue === 'object' && previewValue !== null ? previewValue : null;
+ const propertiesValue = preview ? readOwnDataProperty(preview, 'properties') : MISSING_PROPERTY;
+ const propertyCount = safeArrayLength(propertiesValue);
+ const description =
+ readOwnString(remoteObject, 'description') ??
+ (preview ? readOwnString(preview, 'description') : undefined) ??
+ readOwnString(remoteObject, 'type') ??
+ '[Unavailable]';
+ if (propertyCount === null || propertyCount === 0) return boundedFragment(description);
+
+ const subtype = (preview ? readOwnString(preview, 'subtype') : undefined) ?? readOwnString(remoteObject, 'subtype');
+ const isArray = subtype === 'array';
+ const formatted: string[] = [];
+ const count = Math.min(propertyCount, MAX_PREVIEW_PROPERTIES);
+ for (let index = 0; index < count; index += 1) {
+ const property = readOwnDataProperty(propertiesValue as object, String(index));
+ formatted.push(formatPreviewProperty(property, isArray));
+ }
+ const overflow = preview ? readOwnDataProperty(preview, 'overflow') === true : false;
+ if (overflow || propertyCount > MAX_PREVIEW_PROPERTIES) formatted.push('…');
+ return isArray ? `[${formatted.join(', ')}]` : `{${formatted.join(', ')}}`;
+}
+
+function formatRemoteObject(value: unknown): string {
+ if (value === MISSING_PROPERTY || value === UNAVAILABLE_PROPERTY) return '[Unavailable]';
+ const primitive = formatPrimitive(value);
+ if (primitive !== null) return primitive;
+ if (typeof value !== 'object' || value === null) return '[Unavailable]';
+
+ const type = readOwnString(value, 'type');
+ const subtype = readOwnString(value, 'subtype');
+ if (type === 'undefined') return 'undefined';
+ if (subtype === 'null') return 'null';
+
+ const unserializableValue = readOwnString(value, 'unserializableValue');
+ if (unserializableValue !== undefined) return boundedFragment(unserializableValue);
+
+ const remoteValue = readOwnDataProperty(value, 'value');
+ if (remoteValue !== MISSING_PROPERTY && remoteValue !== UNAVAILABLE_PROPERTY) {
+ const formattedValue = formatPrimitive(remoteValue);
+ if (formattedValue !== null) return formattedValue;
+ }
+ if (subtype === 'error') {
+ const errorDescription = readOwnString(value, 'description');
+ if (errorDescription !== undefined) return boundedFragment(errorDescription);
+ }
+ return formatRemotePreview(value);
+}
+
+function formatConsoleArguments(value: unknown): string | null {
+ const length = safeArrayLength(value);
+ if (length === null) return null;
+ if (length === 0) return '';
+ const count = Math.min(length, MAX_CONSOLE_ARGUMENTS);
+ const arguments_: unknown[] = [];
+ const formatted: string[] = [];
+ let formattedLength = 0;
+ let inputTruncated = false;
+ for (let index = 0; index < count; index += 1) {
+ const argument = readOwnDataProperty(value as object, String(index));
+ const formattedArgument = formatRemoteObject(argument);
+ const separatorLength = formatted.length === 0 ? 0 : 1;
+ const remainingLength = MAX_CONSOLE_FRAGMENT_LENGTH - formattedLength - separatorLength;
+ if (remainingLength <= 0) {
+ formatted.push('…');
+ inputTruncated = true;
+ break;
+ }
+ arguments_.push(argument);
+ if (formattedArgument.length > remainingLength) {
+ formatted.push(`${formattedArgument.slice(0, Math.max(0, remainingLength - 1))}…`);
+ inputTruncated = true;
+ break;
+ }
+ formatted.push(formattedArgument);
+ formattedLength += separatorLength + formattedArgument.length;
+ }
+ if (!inputTruncated && length > arguments_.length && formattedLength < MAX_CONSOLE_FRAGMENT_LENGTH) {
+ formatted.push('…');
+ }
+
+ const firstValue =
+ typeof arguments_[0] === 'object' && arguments_[0] !== null
+ ? readOwnDataProperty(arguments_[0], 'value')
+ : MISSING_PROPERTY;
+ if (typeof firstValue !== 'string' || !/%[%Ocdfios]/.test(formatted[0] ?? '')) {
+ return formatted.join(' ');
+ }
+
+ let nextArgument = 1;
+ const substituted = (formatted[0] ?? '').replaceAll(/%([%Ocdfios])/g, (match: string, specifier: string) => {
+ if (specifier === '%') return '%';
+ if (nextArgument >= arguments_.length) return match;
+ const argument = formatted[nextArgument++] ?? '';
+ if (specifier === 'c') return '';
+ if (specifier === 'd' || specifier === 'i') return String(Number.parseInt(argument, 10));
+ if (specifier === 'f') return String(Number.parseFloat(argument));
+ return argument;
+ });
+ return [substituted, ...formatted.slice(nextArgument)].join(' ');
+}
+
+function redactConsoleText(text: string): string {
+ return text
+ .replaceAll(STANDALONE_JWT, '[REDACTED]')
+ .replaceAll(STANDALONE_AWS_ACCESS_KEY, '[REDACTED]')
+ .replaceAll(STANDALONE_GITHUB_TOKEN, '[REDACTED]')
+ .replaceAll(STANDALONE_GOOGLE_API_KEY, '[REDACTED]')
+ .replaceAll(STANDALONE_SLACK_TOKEN, '[REDACTED]')
+ .replaceAll(/\b(?:sk-(?:proj-|svcacct-)?[\w-]{12,}|sess-[\w-]{12,})\b/g, '[REDACTED]')
+ .replaceAll(/\bbearer\s+[\w+./~-]+=*/gi, 'Bearer [REDACTED]')
+ .replaceAll(
+ /(["']?\b(?:authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\n\r,]+)/gi,
+ '$1[REDACTED]',
+ )
+ .replaceAll(
+ /(["']?\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|password|passwd|credential|secret|private[_-]?input)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\n\r&,;}]+)/gi,
+ '$1[REDACTED]',
+ )
+ .replaceAll(
+ /([&?](?:access_token|refresh_token|id_token|api_key|token|code|password|key|session)=)[^\s#&]+/gi,
+ '$1[REDACTED]',
+ );
+}
+
+function normalizeConsoleLevel(level: unknown): ChromiumConsoleLevel {
+ switch (level) {
+ case 'error':
+ case 'assert': {
+ return ChromiumConsoleLevel.Error;
+ }
+ case 'warn':
+ case 'warning': {
+ return ChromiumConsoleLevel.Warning;
+ }
+ case 'info': {
+ return ChromiumConsoleLevel.Info;
+ }
+ case 'debug':
+ case 'verbose':
+ case 'trace': {
+ return ChromiumConsoleLevel.Debug;
+ }
+ default: {
+ return ChromiumConsoleLevel.Log;
+ }
+ }
+}
+
+function makeConsoleEntry(
+ level: unknown,
+ message: string,
+ source: ChromiumConsoleSource,
+ timestamp: unknown,
+): ChromiumConsoleEntry {
+ const redacted = boundedFragment(message);
+ return {
+ level: normalizeConsoleLevel(level),
+ message:
+ redacted.length > MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH
+ ? `${redacted.slice(0, MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH)}…`
+ : redacted,
+ source,
+ timestamp: typeof timestamp === 'number' && Number.isFinite(timestamp) ? timestamp : Date.now(),
+ };
+}
+
+export function formatChromiumConsoleEvent(event: ChromiumDevToolsEvent): ChromiumConsoleEntry | null {
+ if (typeof event !== 'object' || event === null) return null;
+ const method = readOwnString(event, 'method');
+ const paramsValue = readOwnDataProperty(event, 'params');
+ if (typeof paramsValue !== 'object' || paramsValue === null) return null;
+
+ if (method === 'Runtime.consoleAPICalled') {
+ const message = formatConsoleArguments(readOwnDataProperty(paramsValue, 'args'));
+ if (message === null) return null;
+ return makeConsoleEntry(
+ readOwnDataProperty(paramsValue, 'type'),
+ message,
+ ChromiumConsoleSource.Console,
+ readOwnDataProperty(paramsValue, 'timestamp'),
+ );
+ }
+
+ if (method === 'Log.entryAdded') {
+ const entry = readOwnDataProperty(paramsValue, 'entry');
+ if (typeof entry !== 'object' || entry === null) return null;
+ const message = readOwnString(entry, 'text');
+ if (message === undefined) return null;
+ return makeConsoleEntry(
+ readOwnDataProperty(entry, 'level'),
+ message,
+ ChromiumConsoleSource.Browser,
+ readOwnDataProperty(entry, 'timestamp'),
+ );
+ }
+
+ if (method === 'Runtime.exceptionThrown') {
+ const details = readOwnDataProperty(paramsValue, 'exceptionDetails');
+ if (typeof details !== 'object' || details === null) return null;
+ const exception = readOwnDataProperty(details, 'exception');
+ const description =
+ typeof exception === 'object' && exception !== null ? readOwnString(exception, 'description') : undefined;
+ const message = description ?? readOwnString(details, 'text') ?? 'Uncaught exception';
+ return makeConsoleEntry(
+ ChromiumConsoleLevel.Error,
+ message,
+ ChromiumConsoleSource.Exception,
+ readOwnDataProperty(paramsValue, 'timestamp'),
+ );
+ }
+
+ return null;
+}
diff --git a/npm_modules/cli/src/debugger/consoleSseWriter.spec.ts b/npm_modules/cli/src/debugger/consoleSseWriter.spec.ts
new file mode 100644
index 000000000..78878c22b
--- /dev/null
+++ b/npm_modules/cli/src/debugger/consoleSseWriter.spec.ts
@@ -0,0 +1,91 @@
+import 'jasmine';
+import type { ServerResponse } from 'node:http';
+import { ConsoleSseWriter, MAX_CONSOLE_SSE_BUFFERED_EVENTS } from './server';
+
+class MockSseResponse {
+ readonly chunks: string[] = [];
+ readonly writeResults: boolean[] = [];
+ private readonly drainListeners = new Set<() => void>();
+
+ emitDrain(): void {
+ const listeners = Array.from(this.drainListeners);
+ this.drainListeners.clear();
+ for (const listener of listeners) listener();
+ }
+
+ listenerCount(event: string): number {
+ return event === 'drain' ? this.drainListeners.size : 0;
+ }
+
+ off(event: string, listener: () => void): this {
+ if (event === 'drain') this.drainListeners.delete(listener);
+ return this;
+ }
+
+ once(event: string, listener: () => void): this {
+ if (event === 'drain') this.drainListeners.add(listener);
+ return this;
+ }
+
+ write(chunk: string): boolean {
+ this.chunks.push(chunk);
+ return this.writeResults.shift() ?? true;
+ }
+}
+
+describe('Chromium console SSE backpressure', () => {
+ it('buffers complete SSE frames while blocked and flushes them in order on drain', () => {
+ const response = new MockSseResponse();
+ response.writeResults.push(false, true, true);
+ const failures: Error[] = [];
+ const writer = new ConsoleSseWriter(response as unknown as ServerResponse, error => failures.push(error));
+
+ expect(writer.send('console', { message: 'first' })).toBeTrue();
+ expect(writer.send('console', { message: 'second' })).toBeTrue();
+ expect(writer.send('heartbeat', { sequence: 3 })).toBeTrue();
+ expect(response.chunks).toHaveSize(1);
+
+ response.emitDrain();
+
+ expect(response.chunks).toHaveSize(3);
+ expect(response.chunks[0]).toContain('"first"');
+ expect(response.chunks[1]).toContain('"second"');
+ expect(response.chunks[2]).toContain('event: heartbeat');
+ expect(failures).toEqual([]);
+ });
+
+ it('fails closed instead of accumulating an unbounded backpressure queue', () => {
+ const response = new MockSseResponse();
+ response.writeResults.push(false);
+ const failures: Error[] = [];
+ const writer = new ConsoleSseWriter(response as unknown as ServerResponse, error => failures.push(error));
+
+ expect(writer.send('console', { sequence: 0 })).toBeTrue();
+ for (let index = 0; index < MAX_CONSOLE_SSE_BUFFERED_EVENTS; index += 1) {
+ expect(writer.send('console', { sequence: index + 1 })).toBeTrue();
+ }
+ expect(writer.send('console', { sequence: MAX_CONSOLE_SSE_BUFFERED_EVENTS + 1 })).toBeFalse();
+
+ expect(failures).toHaveSize(1);
+ expect(failures[0]?.message).toContain('backpressure limit');
+ expect(writer.send('console', { sequence: 999 })).toBeFalse();
+ response.emitDrain();
+ expect(response.chunks).toHaveSize(1);
+ });
+
+ it('fails closed on unserializable payloads and removes pending drain listeners on close', () => {
+ const response = new MockSseResponse();
+ response.writeResults.push(false);
+ const failures: Error[] = [];
+ const writer = new ConsoleSseWriter(response as unknown as ServerResponse, error => failures.push(error));
+ const cyclic: Record = {};
+ cyclic['self'] = cyclic;
+
+ expect(writer.send('console', { message: 'accepted' })).toBeTrue();
+ expect(response.listenerCount('drain')).toBe(1);
+ expect(writer.send('console', cyclic)).toBeFalse();
+
+ expect(failures).toHaveSize(1);
+ expect(response.listenerCount('drain')).toBe(0);
+ });
+});
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/devtoolsPanel.spec.ts b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
new file mode 100644
index 000000000..5d476b1c2
--- /dev/null
+++ b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
@@ -0,0 +1,3178 @@
+import 'jasmine';
+import fs from 'node:fs';
+import path from 'node:path';
+import { Script } from 'node:vm';
+
+const TEST_API_TOKEN = 'devtools-panel-test-token';
+
+function debuggerApiHeaders(headers: Record): Record {
+ return { ...headers, 'X-Valdi-Debugger-Token': TEST_API_TOKEN };
+}
+
+function debuggerEventSourceUrl(pathname: string): string {
+ const url = new URL(pathname, 'http://127.0.0.1:18768');
+ url.searchParams.set('valdiDebuggerToken', TEST_API_TOKEN);
+ return url.toString();
+}
+
+interface DevToolsTreeNode {
+ bounds?: { height: number; width: number; x: number; y: number };
+ children: DevToolsTreeNode[];
+ component?: {
+ elementId?: string;
+ key: string;
+ name: string;
+ properties?: Record;
+ propertyEdits?: Record;
+ state?: string;
+ };
+ element?: {
+ attributes: Record;
+ dom: { attributes: Record; tagName: string; textContent?: string };
+ id: number;
+ };
+ id: string;
+ tag: string;
+}
+
+interface TreeStubElement {
+ checked: boolean;
+ className: string;
+ innerHTML: string;
+ scrollTop: number;
+ textContent: string;
+ value: string;
+ addEventListener(): void;
+ contains(): boolean;
+ focus(): void;
+ removeAttribute(): void;
+ setAttribute(): void;
+}
+
+interface DevToolsHierarchyPanel {
+ inspectorContent: TreeStubElement;
+ state: {
+ activeDetail: string;
+ componentPropertyEdit: {
+ error: string | null;
+ focused: boolean;
+ operationGeneration: number;
+ pending: boolean;
+ };
+ autoRefresh: boolean;
+ expandedNodeIds: Set;
+ highlightMayBeActive: boolean;
+ highlightRequestTail: Promise;
+ highlightTimer: number | null;
+ refreshPending: boolean;
+ refreshTimer: number | null;
+ search: string;
+ selectedNodeId: string | null;
+ snapshot: { tree: DevToolsTreeNode } | null;
+ snapshotGeneration: number;
+ target: { capabilities?: string[]; id: string; sessionId: string } | null;
+ targetGeneration: number;
+ };
+ treeContent: TreeStubElement;
+ clearTargetPresentation(message: string): void;
+ findNode(id: string): DevToolsTreeNode | null;
+ inspectedNodeId(node: DevToolsTreeNode | null): string | null;
+ queueHighlight(nodeId: string | null): void;
+ refreshSnapshot(): Promise;
+ renderInspector(): void;
+ renderTree(): void;
+ selectNode(id: string): void;
+ startRefreshTimer(): void;
+ submitComponentPropertyEdit(
+ componentId: string,
+ propertyName: string,
+ componentToken: string,
+ snapshotRevision: number,
+ value: boolean | number | string,
+ ): Promise;
+}
+
+interface StubElement {
+ checked: boolean;
+ className: string;
+ innerHTML: string;
+ scrollHeight: number;
+ scrollTop: number;
+ textContent: string;
+ value: string;
+ addEventListener(type: string, listener: (event: unknown) => void): void;
+ dispatch(type: string): void;
+}
+
+interface MockConsoleEventSource {
+ closed: boolean;
+ url: string;
+ addEventListener(type: string, listener: (event: { data: string }) => void): void;
+ close(): void;
+ emit(type: string, payload: Record): void;
+}
+
+interface PickerTarget {
+ attachable: boolean;
+ capabilities: string[];
+ id: string;
+ identityMode: 'inspected-page' | 'target-id';
+ name: string;
+ platform: string;
+ port?: number;
+ sessionId?: string;
+ state: 'attached' | 'available' | 'waiting';
+ transport: string;
+}
+
+interface PickerStubEvent {
+ currentTarget: PickerStubElement;
+ key: string;
+ target: PickerStubElement;
+ preventDefault(): void;
+ stopPropagation(): void;
+}
+
+interface PickerStubElement {
+ checked: boolean;
+ children: PickerStubElement[];
+ classList: {
+ contains(value: string): boolean;
+ toggle(value: string, enabled: boolean): void;
+ };
+ className: string;
+ dataset: Record;
+ disabled: boolean;
+ hidden: boolean;
+ id: string;
+ innerHTML: string;
+ open: boolean;
+ scrollHeight: number;
+ scrollTop: number;
+ selected: boolean;
+ style: Record;
+ tabIndex: number;
+ textContent: string;
+ title: string;
+ value: string;
+ addEventListener(type: string, listener: (event: PickerStubEvent) => void): void;
+ append(child: PickerStubElement): void;
+ closest(selector: string): PickerStubElement | null;
+ contains(element?: PickerStubElement): boolean;
+ dispatch(type: string, properties?: Partial): void;
+ focus(): void;
+ getAttribute(name: string): string | null;
+ getBoundingClientRect(): { bottom: number; height: number; left: number; right: number; top: number; width: number };
+ querySelector(selector: string): PickerStubElement | null;
+ querySelectorAll(selector: string): PickerStubElement[];
+ removeAttribute(name: string): void;
+ replaceChildren(...children: PickerStubElement[]): void;
+ scrollIntoView(): void;
+ setAttribute(name: string, value: string): void;
+ setSelectionRange(): void;
+}
+
+interface PickerPanel {
+ state: {
+ activeDetail: string;
+ activeSection: string;
+ consoleEntries: Array<{ kind: string; value: string }>;
+ consoleEntryKeys: Set;
+ consoleHistory: string[];
+ error: string | null;
+ componentPropertyEdit: {
+ error: string | null;
+ focused: boolean;
+ operationGeneration: number;
+ pending: boolean;
+ };
+ expandedNodeIds: Set;
+ highlightMayBeActive: boolean;
+ highlightRequestTail: Promise;
+ highlightTimer: number | null;
+ performance: {
+ data: Record | null;
+ error: string | null;
+ lastTrace: Record | null;
+ ownerIdentity: Record | null;
+ pending: boolean;
+ samples: Record[];
+ snapshotPending: boolean;
+ traceActive: boolean;
+ };
+ refreshPending: boolean;
+ registryPending: boolean;
+ registryRequestGeneration: number;
+ registryTargets: PickerTarget[];
+ selectedNodeId: string | null;
+ snapshot: { tree: DevToolsTreeNode } | null;
+ snapshotGeneration: number;
+ target: PickerTarget | null;
+ targetGeneration: number;
+ targetSwitchMessage: string | null;
+ unavailableTargetId: string | null;
+ };
+ applyDirectTargetSelection(target: PickerTarget | null, options?: Record): boolean;
+ connectToInspectedApplication(): Promise;
+ createComponentPropertyEditor(
+ node: DevToolsTreeNode,
+ propertyName: string,
+ value: boolean | number | string,
+ metadata: { componentToken: string; snapshotRevision: number },
+ ): PickerStubElement;
+ evaluateConsoleExpression(expression: string): Promise;
+ parseTargetRegistry(payload: unknown): PickerTarget[];
+ queueHighlight(nodeId: string | null): void;
+ refreshSnapshot(): Promise;
+ refreshTargetRegistry(): Promise;
+ renderTargetPicker(): void;
+ runPerformanceAction(action: string): Promise;
+ setActiveSection(section: string): void;
+ startConsoleStream(): void;
+ startRefreshTimer(): void;
+ submitComponentPropertyEdit(
+ componentId: string,
+ propertyName: string,
+ componentToken: string,
+ snapshotRevision: number,
+ value: boolean | number | string,
+ ): Promise;
+}
+
+interface DevToolsConsolePanel {
+ clearButton: StubElement;
+ consoleInput: StubElement;
+ consoleMessages: StubElement;
+ liveToggle: StubElement;
+ state: {
+ autoRefresh: boolean;
+ consoleEntries: Array<{ kind: string; value: string }>;
+ consoleHistory: string[];
+ consoleHistoryIndex: number;
+ consoleStream: MockConsoleEventSource | null;
+ consoleStreamTargetKey: string | null;
+ performance: {
+ lastTrace: Record | null;
+ ownerIdentity: Record | null;
+ traceActive: boolean;
+ };
+ target: { id: string; sessionId: string } | null;
+ };
+ evaluateConsoleExpression(expression: string): Promise;
+ startConsoleStream(): void;
+ stopConsoleStream(): void;
+ dispatchWindowEvent(type: string): void;
+}
+
+function componentTree(): DevToolsTreeNode {
+ return {
+ bounds: { height: 80, width: 220, x: 4, y: 8 },
+ children: [
+ {
+ bounds: { height: 80, width: 220, x: 4, y: 8 },
+ children: [
+ {
+ bounds: { height: 20, width: 140, x: 12, y: 24 },
+ children: [
+ {
+ bounds: { height: 20, width: 140, x: 12, y: 24 },
+ children: [],
+ element: {
+ attributes: { accessibilityLabel: 'Continue' },
+ dom: { attributes: { role: 'button' }, tagName: 'span', textContent: 'Continue' },
+ id: 8,
+ },
+ id: '8',
+ tag: 'label',
+ },
+ ],
+ component: {
+ elementId: '8',
+ key: 'nested',
+ name: 'NestedExampleComponent',
+ properties: { enabled: true, title: ' ' },
+ },
+ id: 'component:["7","nested"]',
+ tag: 'NestedExampleComponent',
+ },
+ ],
+ element: {
+ attributes: { accessibilityId: 'sample.root' },
+ dom: { attributes: { id: 'sample.root' }, tagName: 'div' },
+ id: 7,
+ },
+ id: '7',
+ tag: 'view',
+ },
+ ],
+ component: { elementId: '7', key: 'root', name: 'RootExampleComponent' },
+ id: 'component:[null,"root"]',
+ tag: 'RootExampleComponent',
+ };
+}
+
+function editableComponentTree(
+ value: boolean | number | string,
+ componentToken = 'a'.repeat(32),
+ snapshotRevision = 1,
+): DevToolsTreeNode {
+ const tree = componentTree();
+ const nestedComponent = tree.children[0]?.children[0]?.component;
+ if (!nestedComponent) throw new Error('Expected the nested component fixture.');
+ nestedComponent.properties = { ...nestedComponent.properties, enabled: value };
+ nestedComponent.propertyEdits = { enabled: { componentToken, snapshotRevision } };
+ return tree;
+}
+
+function pickerTarget(id: string, overrides: Partial = {}): PickerTarget {
+ return {
+ attachable: true,
+ capabilities: ['components', 'snapshot'],
+ id,
+ identityMode: 'target-id',
+ name: `Target ${id}`,
+ platform: 'macos',
+ port: 9166,
+ state: 'available',
+ transport: 'valdi-daemon',
+ ...overrides,
+ };
+}
+
+function createPickerStubElement(id: string): PickerStubElement {
+ const attributes = new Map();
+ const classes = new Set();
+ const listeners = new Map void>>();
+ const element: PickerStubElement = {
+ checked: true,
+ children: [],
+ classList: {
+ contains: value => classes.has(value),
+ toggle: (value, enabled) => {
+ if (enabled) classes.add(value);
+ else classes.delete(value);
+ },
+ },
+ className: '',
+ dataset: {},
+ disabled: false,
+ hidden: false,
+ id,
+ innerHTML: '',
+ open: false,
+ scrollHeight: 0,
+ scrollTop: 0,
+ selected: false,
+ style: {},
+ tabIndex: -1,
+ textContent: '',
+ title: '',
+ value: '',
+ addEventListener(type, listener) {
+ const entries = listeners.get(type) ?? [];
+ entries.push(listener);
+ listeners.set(type, entries);
+ },
+ append(child) {
+ element.children.push(child);
+ if (child.selected) element.value = child.value;
+ },
+ closest: () => null,
+ contains: () => true,
+ dispatch(type, properties = {}) {
+ const event: PickerStubEvent = {
+ currentTarget: element,
+ key: '',
+ preventDefault() {},
+ stopPropagation() {},
+ target: element,
+ ...properties,
+ };
+ for (const listener of listeners.get(type) ?? []) listener(event);
+ },
+ focus() {},
+ getAttribute: name => attributes.get(name) ?? null,
+ getBoundingClientRect: () => ({ bottom: 500, height: 500, left: 0, right: 500, top: 0, width: 500 }),
+ querySelector(selector) {
+ return element.querySelectorAll(selector)[0] ?? null;
+ },
+ querySelectorAll(selector) {
+ const attributeName = /^\[data-([a-z-]+)]$/.exec(selector)?.[1];
+ if (!attributeName) return [];
+ const datasetKey = attributeName.replaceAll(/-([a-z])/g, (_match, character: string) => character.toUpperCase());
+ const matches: PickerStubElement[] = [];
+ const pending = [...element.children];
+ while (pending.length > 0) {
+ const child = pending.shift();
+ if (!child) continue;
+ if (Object.prototype.hasOwnProperty.call(child.dataset, datasetKey)) matches.push(child);
+ pending.push(...child.children);
+ }
+ return matches;
+ },
+ removeAttribute(name) {
+ attributes.delete(name);
+ },
+ replaceChildren(...children) {
+ element.children = children;
+ element.value = '';
+ },
+ scrollIntoView() {},
+ setAttribute(name, value) {
+ attributes.set(name, value);
+ },
+ setSelectionRange() {},
+ };
+ return element;
+}
+
+interface PickerFetchRequest {
+ body?: string;
+ method: string;
+ url: string;
+}
+
+interface PickerResponse {
+ ok: boolean;
+ status: number;
+ json(): Promise>;
+}
+
+interface PickerDeferredResponse {
+ reject(error: Error): void;
+ resolve(payload: Record, ok?: boolean, status?: number): void;
+}
+
+interface PickerHarness {
+ closeTargetIds: Array;
+ elements: Map;
+ eventSources: MockConsoleEventSource[];
+ fetchRequests: PickerFetchRequest[];
+ panel: PickerPanel;
+ queueDeferred(pathname: string): PickerDeferredResponse;
+ queueResponse(pathname: string, payload: Record, ok?: boolean, status?: number): void;
+ runTimer(timerId: number): void;
+ setActiveElement(element: PickerStubElement | null): void;
+}
+
+interface PickerPanelReference {
+ value: PickerPanel | undefined;
+}
+
+function createPickerHarness(search: string): PickerHarness {
+ const pickerTestApiToken = 'devtools-picker-test-token';
+ const treeModelSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8');
+ const rawPanelSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'devtools-panel.js'), 'utf8');
+ const panelSource = rawPanelSource.replace('void connectToInspectedApplication();', 'void 0;');
+ const elements = new Map();
+ const fetchRequests: PickerFetchRequest[] = [];
+ const queuedResponses = new Map>>();
+ const eventSources: MockConsoleEventSource[] = [];
+ const closeTargetIds: Array = [];
+ const timers = new Map void>();
+ const windowListeners = new Map void>();
+ const documentDataset: Record = {};
+ const activePanel: PickerPanelReference = { value: undefined };
+ let nextTimerId = 1;
+ let activeElement: PickerStubElement | null = null;
+
+ function elementForId(id: string): PickerStubElement {
+ let element = elements.get(id);
+ if (!element) {
+ element = createPickerStubElement(id);
+ element.focus = () => {
+ activeElement = element ?? null;
+ };
+ elements.set(id, element);
+ }
+ return element;
+ }
+
+ const mainTabs = ['elements', 'state', 'performance', 'console'].map(section => {
+ const tab = elementForId(`${section}Tab`);
+ tab.dataset['section'] = section;
+ return tab;
+ });
+ const sections = ['elements', 'state', 'performance', 'console'].map(section => {
+ const panelElement = elementForId(`${section}Section`);
+ panelElement.dataset['panel'] = section;
+ return panelElement;
+ });
+
+ const documentObject = {
+ addEventListener() {},
+ get activeElement(): PickerStubElement | null {
+ return activeElement;
+ },
+ createElement(type: string): PickerStubElement {
+ return createPickerStubElement(type);
+ },
+ documentElement: { dataset: documentDataset },
+ getElementById(id: string): PickerStubElement {
+ return elementForId(id);
+ },
+ hidden: false,
+ querySelectorAll(selector: string): PickerStubElement[] {
+ if (selector === '.main-tab') return mainTabs;
+ if (selector === '.detail-tab') return [];
+ if (selector === '.section') return sections;
+ return [];
+ },
+ };
+
+ function response(payload: Record, ok = true, status = ok ? 200 : 400): PickerResponse {
+ return { json: () => Promise.resolve(payload), ok, status };
+ }
+
+ function enqueue(pathname: string, plannedResponse: Promise): void {
+ const entries = queuedResponses.get(pathname) ?? [];
+ entries.push(plannedResponse);
+ queuedResponses.set(pathname, entries);
+ }
+
+ function defaultPayload(pathname: string): Record {
+ if (pathname === '/api/devtools/targets') return { targets: [] };
+ if (pathname === '/api/devtools/snapshot') return { tree: componentTree() };
+ if (pathname === '/api/devtools/highlight') return { highlighted: true };
+ if (pathname === '/api/devtools/evaluate') return { type: 'string', value: 'ok' };
+ if (pathname.endsWith('/trace/stop')) return { traceCount: 0, traces: [] };
+ return {};
+ }
+
+ class PickerEventSource implements MockConsoleEventSource {
+ closed = false;
+ private readonly listeners = new Map void>>();
+
+ constructor(readonly url: string) {
+ eventSources.push(this);
+ }
+
+ addEventListener(type: string, listener: (event: { data: string }) => void): void {
+ const entries = this.listeners.get(type) ?? [];
+ entries.push(listener);
+ this.listeners.set(type, entries);
+ }
+
+ close(): void {
+ closeTargetIds.push(activePanel.value?.state.target?.id ?? null);
+ this.closed = true;
+ }
+
+ emit(type: string, payload: Record): void {
+ for (const listener of this.listeners.get(type) ?? []) listener({ data: JSON.stringify(payload) });
+ }
+ }
+
+ const windowObject = {
+ addEventListener(type: string, listener: () => void) {
+ windowListeners.set(type, listener);
+ },
+ clearInterval() {},
+ clearTimeout(timerId: number) {
+ timers.delete(timerId);
+ },
+ confirm: () => true,
+ location: { origin: 'http://127.0.0.1:18768', search },
+ parent: {},
+ removeEventListener() {},
+ setInterval(callback: () => void): number {
+ const timerId = nextTimerId++;
+ timers.set(timerId, callback);
+ return timerId;
+ },
+ setTimeout(callback: () => void): number {
+ const timerId = nextTimerId++;
+ timers.set(timerId, callback);
+ return timerId;
+ },
+ };
+
+ const panel = new Script(
+ `${treeModelSource}\n${panelSource}\n({ applyDirectTargetSelection, connectToInspectedApplication, createComponentPropertyEditor, evaluateConsoleExpression, parseTargetRegistry, queueHighlight, refreshSnapshot, refreshTargetRegistry, renderTargetPicker, runPerformanceAction, setActiveSection, startConsoleStream, startRefreshTimer, state, submitComponentPropertyEdit })`,
+ ).runInNewContext({
+ Blob,
+ EventSource: PickerEventSource,
+ URL,
+ URLSearchParams,
+ console,
+ debuggerApiHeaders: (headers: Record) => ({
+ ...headers,
+ 'X-Valdi-Debugger-Token': pickerTestApiToken,
+ }),
+ debuggerEventSourceUrl: (pathname: string) => {
+ const url = new URL(pathname, 'http://127.0.0.1:18768');
+ url.searchParams.set('valdiDebuggerToken', pickerTestApiToken);
+ return url.toString();
+ },
+ document: documentObject,
+ fetch: (input: URL, options: { body?: string; method: string }) => {
+ const url = new URL(input.toString());
+ fetchRequests.push({
+ ...(options.body === undefined ? {} : { body: options.body }),
+ method: options.method,
+ url: url.toString(),
+ });
+ const entries = queuedResponses.get(url.pathname);
+ return entries?.shift() ?? Promise.resolve(response(defaultPayload(url.pathname)));
+ },
+ navigator: { clipboard: { writeText: () => Promise.resolve() } },
+ window: windowObject,
+ }) as PickerPanel;
+ activePanel.value = panel;
+
+ return {
+ closeTargetIds,
+ elements,
+ eventSources,
+ fetchRequests,
+ panel,
+ queueDeferred(pathname) {
+ let resolveResponse: ((value: PickerResponse) => void) | undefined;
+ let rejectResponse: ((error: Error) => void) | undefined;
+ enqueue(
+ pathname,
+ new Promise((resolve, reject) => {
+ resolveResponse = resolve;
+ rejectResponse = reject;
+ }),
+ );
+ return {
+ reject(error) {
+ if (!rejectResponse) throw new Error('Deferred response was not initialized.');
+ rejectResponse(error);
+ },
+ resolve(payload, ok = true, status = ok ? 200 : 400) {
+ if (!resolveResponse) throw new Error('Deferred response was not initialized.');
+ resolveResponse(response(payload, ok, status));
+ },
+ };
+ },
+ queueResponse(pathname, payload, ok = true, status = ok ? 200 : 400) {
+ enqueue(pathname, Promise.resolve(response(payload, ok, status)));
+ },
+ runTimer(timerId) {
+ const callback = timers.get(timerId);
+ if (!callback) throw new Error(`Unknown timer ${timerId}.`);
+ timers.delete(timerId);
+ callback();
+ },
+ setActiveElement(element) {
+ activeElement = element;
+ },
+ };
+}
+
+async function flushPickerPromises(): Promise {
+ for (let index = 0; index < 10; index++) await Promise.resolve();
+}
+
+function requiredPickerElement(harness: PickerHarness, id: string): PickerStubElement {
+ const element = harness.elements.get(id);
+ if (!element) throw new Error(`Expected picker element ${id}.`);
+ return element;
+}
+
+describe('integrated DevTools component hierarchy', () => {
+ const hierarchyTestApiToken = 'devtools-hierarchy-test-token';
+ let fetchRequests: Array<{ body?: string; url: string }>;
+ let fetchResponse: Record;
+ let queuedFetchResponses: Array> }>>;
+ let panel: DevToolsHierarchyPanel;
+ let timers: Map void>;
+
+ beforeEach(() => {
+ fetchRequests = [];
+ fetchResponse = { highlighted: true };
+ queuedFetchResponses = [];
+ timers = new Map();
+ let nextTimerId = 1;
+ const treeModelSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8');
+ const rawPanelSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'devtools-panel.js'), 'utf8');
+ const panelSource = rawPanelSource.replace('void connectToInspectedApplication();', 'void 0;');
+ const elements = new Map();
+ const document = {
+ addEventListener() {},
+ documentElement: { dataset: {} },
+ getElementById(id: string): TreeStubElement {
+ let element = elements.get(id);
+ if (element === undefined) {
+ element = {
+ checked: true,
+ className: '',
+ innerHTML: '',
+ scrollTop: 0,
+ textContent: '',
+ value: '',
+ addEventListener() {},
+ contains: () => false,
+ focus() {},
+ removeAttribute() {},
+ setAttribute() {},
+ };
+ elements.set(id, element);
+ }
+ return element;
+ },
+ querySelectorAll(): TreeStubElement[] {
+ return [];
+ },
+ };
+ const window = {
+ addEventListener() {},
+ clearInterval(timerId: number) {
+ timers.delete(timerId);
+ },
+ clearTimeout(timerId: number) {
+ timers.delete(timerId);
+ },
+ location: {
+ origin: 'http://127.0.0.1:18768',
+ search:
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456',
+ },
+ parent: {},
+ setInterval(callback: () => void): number {
+ const timerId = nextTimerId++;
+ timers.set(timerId, callback);
+ return timerId;
+ },
+ setTimeout(callback: () => void): number {
+ const timerId = nextTimerId++;
+ timers.set(timerId, callback);
+ return timerId;
+ },
+ };
+
+ panel = new Script(
+ `${treeModelSource}\n${panelSource}\n({ clearTargetPresentation, findNode, inspectedNodeId, inspectorContent: elements.inspector, queueHighlight, refreshSnapshot, renderInspector, renderTree, selectNode, startRefreshTimer, state, submitComponentPropertyEdit, treeContent: elements.tree })`,
+ ).runInNewContext({
+ URL,
+ URLSearchParams,
+ console,
+ debuggerApiHeaders: (headers: Record) => ({
+ ...headers,
+ 'X-Valdi-Debugger-Token': hierarchyTestApiToken,
+ }),
+ debuggerEventSourceUrl: (pathname: string) => {
+ const url = new URL(pathname, 'http://127.0.0.1:18768');
+ url.searchParams.set('valdiDebuggerToken', hierarchyTestApiToken);
+ return url.toString();
+ },
+ document,
+ fetch: (url: URL, options: { body?: string }) => {
+ fetchRequests.push({ ...(options.body === undefined ? {} : { body: options.body }), url: url.toString() });
+ return (
+ queuedFetchResponses.shift() ?? Promise.resolve({ json: () => Promise.resolve(fetchResponse), ok: true })
+ );
+ },
+ navigator: { clipboard: { writeText: () => Promise.resolve() } },
+ window,
+ }) as DevToolsHierarchyPanel;
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'snapshot', 'highlight', 'console', 'performance'],
+ id: 'owl:web-preview',
+ sessionId: 'web-preview',
+ };
+ panel.state.snapshot = { tree: componentTree() };
+ panel.state.snapshotGeneration = 1;
+ });
+
+ it('renders and searches component rows without hiding their physical descendants', () => {
+ panel.state.expandedNodeIds.add('component:[null,"root"]');
+ panel.state.expandedNodeIds.add('7');
+ panel.state.expandedNodeIds.add('component:["7","nested"]');
+ panel.state.selectedNodeId = '8';
+
+ panel.renderTree();
+
+ expect(panel.treeContent.innerHTML).toContain('class="tree-row component-row"');
+ expect(panel.treeContent.innerHTML).toContain('RootExampleComponent ');
+ expect(panel.treeContent.innerHTML).toContain('NestedExampleComponent ');
+ expect(panel.treeContent.innerHTML).toContain('data-node-id="7"');
+ expect(panel.treeContent.innerHTML).toContain('data-node-id="8"');
+ expect(panel.treeContent.innerHTML).toContain('Continue ');
+
+ panel.state.search = 'nestedexample';
+ panel.renderTree();
+ expect(panel.treeContent.innerHTML).toContain('RootExampleComponent');
+ expect(panel.treeContent.innerHTML).toContain('data-node-id="7"');
+ expect(panel.treeContent.innerHTML).toContain('NestedExampleComponent');
+ expect(panel.treeContent.innerHTML).not.toContain('data-node-id="8"');
+ });
+
+ it('keeps component selection while inspecting the current backing element', () => {
+ panel.state.activeDetail = 'dom';
+
+ panel.selectNode('component:["7","nested"]');
+
+ expect(panel.state.selectedNodeId).toBe('component:["7","nested"]');
+ expect(panel.inspectorContent.innerHTML).toContain('Valdi component');
+ expect(panel.inspectorContent.innerHTML).toContain('NestedExampleComponent');
+ expect(panel.inspectorContent.innerHTML).toContain('elementId ');
+ expect(panel.inspectorContent.innerHTML).toContain('Rendered <span>');
+ expect(panel.inspectorContent.innerHTML).toContain('Text content');
+ });
+
+ it('renders escaped read-only Valdi props only when the target advertises the capability', () => {
+ panel.selectNode('component:["7","nested"]');
+
+ expect(panel.inspectorContent.innerHTML).toContain('aria-label="Valdi props"');
+ expect(panel.inspectorContent.innerHTML).toContain('read only');
+ expect(panel.inspectorContent.innerHTML).toContain('<img src=x onerror=alert(1)>');
+ expect(panel.inspectorContent.innerHTML).not.toContain(' {
+ const tree = editableComponentTree(true);
+ const nestedComponent = tree.children[0]?.children[0]?.component;
+ if (!nestedComponent) throw new Error('Expected the nested component fixture.');
+ const unsafePropertyName = 'caption" onfocus="alert(1)';
+ nestedComponent.properties = {
+ ...nestedComponent.properties,
+ [unsafePropertyName]: '',
+ coercion: 'read only',
+ invalid: 42,
+ };
+ nestedComponent.propertyEdits = {
+ ...nestedComponent.propertyEdits,
+ [unsafePropertyName]: { componentToken: 'b'.repeat(32), snapshotRevision: 1 },
+ coercion: {
+ componentToken: {
+ toString: () => {
+ throw new Error('Malformed metadata must not be coerced.');
+ },
+ } as unknown as string,
+ snapshotRevision: 1,
+ },
+ invalid: { componentToken: 'INVALID', snapshotRevision: 1 },
+ };
+ panel.state.snapshot = { tree };
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'component-property-edit', 'snapshot'],
+ id: 'owl:web-preview',
+ sessionId: 'web-preview',
+ };
+
+ panel.selectNode('component:["7","nested"]');
+
+ expect(panel.inspectorContent.innerHTML).toContain('aria-label="Valdi props"');
+ expect(panel.inspectorContent.innerHTML).toContain('editable scalars');
+ expect(panel.inspectorContent.innerHTML).toContain('data-component-property-editor-slot');
+ expect(panel.inspectorContent.innerHTML).not.toContain('data-component-token');
+ expect(panel.inspectorContent.innerHTML).not.toContain('data-property-name');
+ expect(panel.inspectorContent.innerHTML).not.toContain('data-snapshot-revision');
+ expect(panel.inspectorContent.innerHTML).not.toContain('b'.repeat(32));
+ expect(panel.inspectorContent.innerHTML).not.toContain(unsafePropertyName);
+ expect(panel.inspectorContent.innerHTML).not.toContain('');
+ expect(panel.inspectorContent.innerHTML).not.toContain('<svg onload=alert(1)>');
+ expect(panel.inspectorContent.innerHTML).toContain(
+ 'invalid :',
+ );
+ expect(panel.inspectorContent.innerHTML).toContain(
+ '
coercion :',
+ );
+
+ panel.state.target.capabilities = ['components', 'component-properties', 'snapshot'];
+ panel.renderInspector();
+
+ expect(panel.inspectorContent.innerHTML).not.toContain('data-component-property-editor-slot');
+ expect(panel.inspectorContent.innerHTML).toContain('read only');
+ expect(panel.inspectorContent.innerHTML).toContain('caption" onfocus="alert(1)');
+ expect(panel.inspectorContent.innerHTML).toContain('<svg onload=alert(1)>');
+ });
+
+ it('pauses automatic snapshot refresh while a property editor is focused or a mutation is pending', async () => {
+ panel.state.componentPropertyEdit.focused = true;
+ panel.startRefreshTimer();
+ const focusedTimerId = panel.state.refreshTimer;
+ if (focusedTimerId === null) throw new Error('Expected the focused refresh timer.');
+ const focusedTimer = timers.get(focusedTimerId);
+ if (!focusedTimer) throw new Error('Expected the focused refresh callback.');
+ focusedTimer();
+ await Promise.resolve();
+ expect(fetchRequests).toEqual([]);
+
+ panel.state.componentPropertyEdit.focused = false;
+ panel.state.componentPropertyEdit.pending = true;
+ panel.startRefreshTimer();
+ const pendingTimerId = panel.state.refreshTimer;
+ if (pendingTimerId === null) throw new Error('Expected the pending refresh timer.');
+ const pendingTimer = timers.get(pendingTimerId);
+ if (!pendingTimer) throw new Error('Expected the pending refresh callback.');
+ pendingTimer();
+ await Promise.resolve();
+ expect(fetchRequests).toEqual([]);
+
+ panel.state.componentPropertyEdit.pending = false;
+ fetchResponse = { tree: componentTree() };
+ panel.startRefreshTimer();
+ const activeTimerId = panel.state.refreshTimer;
+ if (activeTimerId === null) throw new Error('Expected the active refresh timer.');
+ const activeTimer = timers.get(activeTimerId);
+ if (!activeTimer) throw new Error('Expected the active refresh callback.');
+ activeTimer();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(fetchRequests.filter(request => new URL(request.url).pathname === '/api/devtools/snapshot').length).toBe(1);
+ });
+
+ it('posts the exact captured identity and refreshes after a successful scalar edit', async () => {
+ panel.state.snapshot = { tree: editableComponentTree(true, 'a'.repeat(32), 7) };
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'component-property-edit', 'snapshot'],
+ id: 'owl:web-preview',
+ sessionId: 'web-preview',
+ };
+ panel.selectNode('component:["7","nested"]');
+ queuedFetchResponses.push(
+ Promise.resolve({ json: () => Promise.resolve({ updated: true }), ok: true }),
+ Promise.resolve({
+ json: () => Promise.resolve({ tree: editableComponentTree(false, 'b'.repeat(32), 8) }),
+ ok: true,
+ }),
+ );
+
+ await panel.submitComponentPropertyEdit('component:["7","nested"]', 'enabled', 'a'.repeat(32), 7, false);
+
+ expect(fetchRequests.length).toBe(2);
+ const editRequest = fetchRequests[0];
+ if (!editRequest?.body) throw new Error('Expected the serialized property edit request.');
+ expect(editRequest.url).toBe('http://127.0.0.1:18768/api/devtools/component-property');
+ expect(JSON.parse(editRequest.body)).toEqual({
+ componentId: 'component:["7","nested"]',
+ componentToken: 'a'.repeat(32),
+ inspectedUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1',
+ propertyName: 'enabled',
+ protocolVersion: 1,
+ sessionId: 'web-preview',
+ snapshotRevision: 7,
+ targetNonce: 'panel-target-nonce-123456',
+ value: false,
+ });
+ expect(new URL(fetchRequests[1]?.url ?? '').pathname).toBe('/api/devtools/snapshot');
+ expect(panel.state.componentPropertyEdit.pending).toBeFalse();
+ const refreshedComponent = panel.state.snapshot?.tree.children[0]?.children[0]?.component;
+ expect(refreshedComponent?.propertyEdits?.['enabled']?.snapshotRevision).toBe(8);
+ });
+
+ it('does not post an edit without the negotiated component-property-edit capability', async () => {
+ panel.state.snapshot = { tree: editableComponentTree(true, 'a'.repeat(32), 7) };
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'snapshot'],
+ id: 'owl:web-preview',
+ sessionId: 'web-preview',
+ };
+ panel.selectNode('component:["7","nested"]');
+
+ await panel.submitComponentPropertyEdit('component:["7","nested"]', 'enabled', 'a'.repeat(32), 7, false);
+
+ expect(fetchRequests).toEqual([]);
+ expect(panel.inspectorContent.innerHTML).toContain('read only');
+ });
+
+ it('keeps properties read only after an edit failure', async () => {
+ panel.state.snapshot = { tree: editableComponentTree(true, 'a'.repeat(32), 7) };
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'component-property-edit', 'snapshot'],
+ id: 'owl:web-preview',
+ sessionId: 'web-preview',
+ };
+ panel.selectNode('component:["7","nested"]');
+ queuedFetchResponses.push(
+ Promise.resolve({
+ json: () => Promise.resolve({ error: 'The component property edit is stale or invalid.' }),
+ ok: false,
+ }),
+ );
+
+ await panel.submitComponentPropertyEdit('component:["7","nested"]', 'enabled', 'a'.repeat(32), 7, false);
+
+ expect(panel.state.componentPropertyEdit.error).toBe('The component property edit is stale or invalid.');
+ expect(panel.inspectorContent.innerHTML).toContain('role="alert"');
+ expect(panel.inspectorContent.innerHTML).toContain('The component property edit is stale or invalid.');
+ expect(panel.inspectorContent.innerHTML).not.toContain('data-component-property-editor-slot');
+ expect(panel.inspectorContent.innerHTML).toContain('read only');
+ expect(panel.inspectorContent.innerHTML).toContain('
enabled :');
+ });
+
+ it('clears pending state without refreshing when selection changes before an edit completes', async () => {
+ panel.state.snapshot = { tree: editableComponentTree(true, 'a'.repeat(32), 7) };
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'component-property-edit', 'snapshot'],
+ id: 'owl:web-preview',
+ sessionId: 'web-preview',
+ };
+ panel.selectNode('component:["7","nested"]');
+ let resolveEdit: ((response: { ok: boolean; json(): Promise
> }) => void) | undefined;
+ queuedFetchResponses.push(
+ new Promise(resolve => {
+ resolveEdit = resolve;
+ }),
+ );
+
+ const staleEdit = panel.submitComponentPropertyEdit(
+ 'component:["7","nested"]',
+ 'enabled',
+ 'a'.repeat(32),
+ 7,
+ false,
+ );
+ await Promise.resolve();
+ panel.selectNode('component:[null,"root"]');
+ const replacementSelectionMarkup = panel.inspectorContent.innerHTML;
+ expect(panel.state.componentPropertyEdit.pending).toBeTrue();
+
+ if (!resolveEdit) throw new Error('Expected the deferred property edit response.');
+ resolveEdit({ json: () => Promise.resolve({ updated: true }), ok: true });
+ await staleEdit;
+
+ expect(panel.state.selectedNodeId).toBe('component:[null,"root"]');
+ expect(panel.state.componentPropertyEdit.pending).toBeFalse();
+ expect(fetchRequests.length).toBe(1);
+ expect(panel.inspectorContent.innerHTML).toBe(replacementSelectionMarkup);
+ });
+
+ it('drops in-flight snapshots that resolve after an editor becomes focused or pending', async () => {
+ panel.state.snapshot = { tree: editableComponentTree(true, 'a'.repeat(32), 7) };
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'component-property-edit', 'snapshot'],
+ id: 'owl:web-preview',
+ sessionId: 'web-preview',
+ };
+ panel.selectNode('component:["7","nested"]');
+ let resolveFocusedSnapshot:
+ | ((response: { ok: boolean; json(): Promise> }) => void)
+ | undefined;
+ queuedFetchResponses.push(
+ new Promise(resolve => {
+ resolveFocusedSnapshot = resolve;
+ }),
+ );
+ const focusedRefresh = panel.refreshSnapshot();
+ await Promise.resolve();
+ panel.state.componentPropertyEdit.focused = true;
+ if (!resolveFocusedSnapshot) throw new Error('Expected the focused snapshot response.');
+ resolveFocusedSnapshot({
+ json: () => Promise.resolve({ tree: editableComponentTree(false, 'b'.repeat(32), 8) }),
+ ok: true,
+ });
+ await focusedRefresh;
+
+ expect(panel.state.refreshPending).toBeFalse();
+ expect(
+ panel.state.snapshot?.tree.children[0]?.children[0]?.component?.propertyEdits?.['enabled']?.snapshotRevision,
+ ).toBe(7);
+
+ panel.state.componentPropertyEdit.focused = false;
+ let resolvePendingSnapshot:
+ | ((response: { ok: boolean; json(): Promise> }) => void)
+ | undefined;
+ let resolveEdit: ((response: { ok: boolean; json(): Promise> }) => void) | undefined;
+ queuedFetchResponses.push(
+ new Promise(resolve => {
+ resolvePendingSnapshot = resolve;
+ }),
+ new Promise(resolve => {
+ resolveEdit = resolve;
+ }),
+ );
+ const pendingRefresh = panel.refreshSnapshot();
+ await Promise.resolve();
+ const edit = panel.submitComponentPropertyEdit('component:["7","nested"]', 'enabled', 'a'.repeat(32), 7, false);
+ await Promise.resolve();
+ expect(panel.state.componentPropertyEdit.pending).toBeTrue();
+ if (!resolvePendingSnapshot) throw new Error('Expected the pending snapshot response.');
+ resolvePendingSnapshot({
+ json: () => Promise.resolve({ tree: editableComponentTree(false, 'b'.repeat(32), 8) }),
+ ok: true,
+ });
+ await pendingRefresh;
+
+ expect(panel.state.refreshPending).toBeFalse();
+ expect(
+ panel.state.snapshot?.tree.children[0]?.children[0]?.component?.propertyEdits?.['enabled']?.snapshotRevision,
+ ).toBe(7);
+ if (!resolveEdit) throw new Error('Expected the deferred property edit response.');
+ resolveEdit({ json: () => Promise.resolve({ updated: false }), ok: true });
+ await edit;
+ expect(panel.state.componentPropertyEdit.pending).toBeFalse();
+ expect(fetchRequests.length).toBe(3);
+ });
+
+ it('does not let a stale edit completion render or refresh a replacement target snapshot', async () => {
+ panel.state.snapshot = { tree: editableComponentTree(true, 'a'.repeat(32), 7) };
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'component-property-edit', 'snapshot'],
+ id: 'owl:web-preview',
+ sessionId: 'web-preview',
+ };
+ panel.selectNode('component:["7","nested"]');
+ let resolveEdit: ((response: { ok: boolean; json(): Promise> }) => void) | undefined;
+ queuedFetchResponses.push(
+ new Promise(resolve => {
+ resolveEdit = resolve;
+ }),
+ );
+
+ const staleEdit = panel.submitComponentPropertyEdit(
+ 'component:["7","nested"]',
+ 'enabled',
+ 'a'.repeat(32),
+ 7,
+ false,
+ );
+ await Promise.resolve();
+ expect(panel.state.componentPropertyEdit.pending).toBeTrue();
+
+ panel.state.targetGeneration++;
+ panel.clearTargetPresentation('Loading the replacement target…');
+ const replacementTree = editableComponentTree('replacement', 'c'.repeat(32), 12);
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'component-property-edit', 'snapshot'],
+ id: 'replacement-target',
+ sessionId: 'replacement-session',
+ };
+ panel.state.snapshot = { tree: replacementTree };
+ panel.selectNode('component:["7","nested"]');
+ const replacementMarkup = panel.inspectorContent.innerHTML;
+
+ if (!resolveEdit) throw new Error('Expected the deferred property edit response.');
+ resolveEdit({ json: () => Promise.resolve({ updated: true }), ok: true });
+ await staleEdit;
+
+ expect(fetchRequests.length).toBe(1);
+ expect(panel.state.target.id).toBe('replacement-target');
+ expect(panel.state.componentPropertyEdit.pending).toBeFalse();
+ expect(panel.inspectorContent.innerHTML).toBe(replacementMarkup);
+ const replacementComponent = panel.state.snapshot?.tree.children[0]?.children[0]?.component;
+ expect(replacementComponent?.properties?.['enabled']).toBe('replacement');
+ expect(replacementComponent?.propertyEdits?.['enabled']?.snapshotRevision).toBe(12);
+ expect(panel.inspectorContent.innerHTML).not.toContain('data-component-token');
+ });
+
+ it('distinguishes omitted properties from a captured empty ViewModel', () => {
+ const node = panel.findNode('component:["7","nested"]');
+ if (!node?.component) throw new Error('Expected the nested component fixture.');
+ delete node.component.properties;
+ panel.selectNode(node.id);
+
+ expect(panel.inspectorContent.innerHTML).not.toContain('aria-label="Valdi props"');
+
+ node.component.properties = {};
+ panel.renderInspector();
+
+ expect(panel.inspectorContent.innerHTML).toContain('aria-label="Valdi props"');
+ expect(panel.inspectorContent.innerHTML).toContain('No properties available.');
+ });
+
+ it('clears properties on target change and drops a stale snapshot response', async () => {
+ let resolveSnapshot: ((response: { ok: boolean; json(): Promise> }) => void) | undefined;
+ queuedFetchResponses.push(
+ new Promise(resolve => {
+ resolveSnapshot = resolve;
+ }),
+ );
+ panel.selectNode('component:["7","nested"]');
+ const staleRefresh = panel.refreshSnapshot();
+ await Promise.resolve();
+
+ panel.state.targetGeneration++;
+ panel.clearTargetPresentation('Loading the replacement target…');
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'snapshot', 'highlight', 'console', 'performance'],
+ id: 'replacement-target',
+ sessionId: 'replacement-session',
+ };
+ panel.renderInspector();
+ expect(panel.state.snapshot).toBeNull();
+ expect(panel.inspectorContent.innerHTML).not.toContain('onerror');
+
+ if (resolveSnapshot === undefined) throw new Error('Expected a deferred hierarchy snapshot.');
+ resolveSnapshot({ json: () => Promise.resolve({ tree: componentTree() }), ok: true });
+ await staleRefresh;
+
+ expect(panel.state.snapshot).toBeNull();
+ expect(panel.inspectorContent.innerHTML).not.toContain('onerror');
+ });
+
+ it('preserves keyed component selection across updates and safely falls back when it disappears', async () => {
+ panel.selectNode('component:["7","nested"]');
+ fetchResponse = { tree: componentTree() };
+
+ await panel.refreshSnapshot();
+
+ expect(panel.state.selectedNodeId).toBe('component:["7","nested"]');
+ expect(panel.state.expandedNodeIds.has('component:[null,"root"]')).toBeTrue();
+ expect(panel.state.expandedNodeIds.has('7')).toBeTrue();
+
+ const elementRoot = componentTree().children[0];
+ const nestedElement = elementRoot?.children[0]?.children[0];
+ if (!elementRoot || !nestedElement) throw new Error('Expected the component fixture to contain a nested element.');
+ elementRoot.children = [nestedElement];
+ fetchResponse = { tree: elementRoot };
+ await panel.refreshSnapshot();
+
+ expect(panel.state.selectedNodeId).toBe('7');
+ expect(panel.findNode('component:["7","nested"]')).toBeNull();
+ });
+
+ it('maps highlights to backing elements and drops timers from stale snapshot generations', async () => {
+ const nestedComponent = panel.findNode('component:["7","nested"]');
+ panel.queueHighlight(panel.inspectedNodeId(nestedComponent));
+ const currentTimerId = panel.state.highlightTimer;
+ if (currentTimerId === null) throw new Error('Expected a current-generation highlight timer id.');
+ const currentTimer = timers.get(currentTimerId);
+ if (currentTimer === undefined) throw new Error('Expected a current-generation highlight timer.');
+ currentTimer();
+ await Promise.resolve();
+
+ expect(fetchRequests.length).toBe(1);
+ const highlightRequest = fetchRequests[0];
+ if (highlightRequest === undefined || highlightRequest.body === undefined) {
+ throw new Error('Expected a serialized highlight request.');
+ }
+ expect(highlightRequest.url).toBe('http://127.0.0.1:18768/api/devtools/highlight');
+ expect(JSON.parse(highlightRequest.body)).toEqual({
+ inspectedUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1',
+ nodeId: '8',
+ sessionId: 'web-preview',
+ targetNonce: 'panel-target-nonce-123456',
+ });
+
+ panel.queueHighlight('7');
+ const staleTimerId = panel.state.highlightTimer;
+ if (staleTimerId === null) throw new Error('Expected a queued highlight timer id.');
+ const staleTimer = timers.get(staleTimerId);
+ if (staleTimer === undefined) throw new Error('Expected a queued highlight timer.');
+ panel.state.snapshotGeneration++;
+ staleTimer();
+ await Promise.resolve();
+
+ expect(fetchRequests.length).toBe(1);
+ });
+
+ it('orders a final clear after an older in-flight highlight request', async () => {
+ let resolveHighlight: ((response: { ok: boolean; json(): Promise> }) => void) | undefined;
+ queuedFetchResponses.push(
+ new Promise(resolve => {
+ resolveHighlight = resolve;
+ }),
+ );
+
+ panel.queueHighlight('8');
+ const highlightTimerId = panel.state.highlightTimer;
+ if (highlightTimerId === null) throw new Error('Expected a highlight timer id.');
+ const highlightTimer = timers.get(highlightTimerId);
+ if (highlightTimer === undefined) throw new Error('Expected a highlight timer.');
+ highlightTimer();
+ await Promise.resolve();
+
+ panel.queueHighlight(null);
+ const clearTimerId = panel.state.highlightTimer;
+ if (clearTimerId === null) throw new Error('Expected a clear timer id.');
+ const clearTimer = timers.get(clearTimerId);
+ if (clearTimer === undefined) throw new Error('Expected a clear timer.');
+ clearTimer();
+ await Promise.resolve();
+
+ expect(fetchRequests.length).toBe(1);
+ expect(panel.state.highlightMayBeActive).toBeTrue();
+ if (resolveHighlight === undefined) throw new Error('Expected a deferred highlight response.');
+ resolveHighlight({ json: () => Promise.resolve({ highlighted: true }), ok: true });
+ await panel.state.highlightRequestTail;
+
+ expect(fetchRequests.length).toBe(2);
+ const clearRequest = fetchRequests[1];
+ if (clearRequest === undefined || clearRequest.body === undefined) {
+ throw new Error('Expected a serialized clear request.');
+ }
+ expect(JSON.parse(clearRequest.body)).toEqual({
+ inspectedUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1',
+ sessionId: 'web-preview',
+ targetNonce: 'panel-target-nonce-123456',
+ });
+ expect(panel.state.highlightMayBeActive).toBeFalse();
+ });
+
+ it('requeues the final clear when a snapshot refresh supersedes a pending clear', async () => {
+ let resolveHighlight: ((response: { ok: boolean; json(): Promise> }) => void) | undefined;
+ queuedFetchResponses.push(
+ new Promise(resolve => {
+ resolveHighlight = resolve;
+ }),
+ );
+
+ panel.queueHighlight('8');
+ const highlightTimerId = panel.state.highlightTimer;
+ if (highlightTimerId === null) throw new Error('Expected a highlight timer id.');
+ const highlightTimer = timers.get(highlightTimerId);
+ if (highlightTimer === undefined) throw new Error('Expected a highlight timer.');
+ highlightTimer();
+ await Promise.resolve();
+
+ fetchResponse = { tree: componentTree() };
+ await panel.refreshSnapshot();
+ const firstClearTimerId = panel.state.highlightTimer;
+ if (firstClearTimerId === null) throw new Error('Expected the first refresh clear timer.');
+ const firstClearTimer = timers.get(firstClearTimerId);
+ if (firstClearTimer === undefined) throw new Error('Expected the first refresh clear timer callback.');
+ firstClearTimer();
+
+ await panel.refreshSnapshot();
+ const finalClearTimerId = panel.state.highlightTimer;
+ if (finalClearTimerId === null) throw new Error('Expected the final refresh clear timer.');
+ const finalClearTimer = timers.get(finalClearTimerId);
+ if (finalClearTimer === undefined) throw new Error('Expected the final refresh clear timer callback.');
+ finalClearTimer();
+ await Promise.resolve();
+
+ const highlightRequestsBeforeResolution = fetchRequests.filter(request =>
+ request.url.endsWith('/api/devtools/highlight'),
+ );
+ expect(highlightRequestsBeforeResolution.length).toBe(1);
+ if (resolveHighlight === undefined) throw new Error('Expected a deferred highlight response.');
+ resolveHighlight({ json: () => Promise.resolve({ highlighted: true }), ok: true });
+ await panel.state.highlightRequestTail;
+
+ const highlightRequests = fetchRequests.filter(request => request.url.endsWith('/api/devtools/highlight'));
+ expect(highlightRequests.length).toBe(2);
+ const finalRequestBody = highlightRequests[1]?.body;
+ if (finalRequestBody === undefined) throw new Error('Expected a serialized final clear request.');
+ const finalRequest = JSON.parse(finalRequestBody) as Record;
+ expect(finalRequest['nodeId']).toBeUndefined();
+ expect(panel.state.highlightMayBeActive).toBeFalse();
+ });
+});
+
+describe('integrated DevTools capability-aware target picker', () => {
+ it('provides a labeled picker, live status, and accessible tab relationships', () => {
+ const html = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'devtools-panel.html'), 'utf8');
+ const css = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'devtools-panel.css'), 'utf8');
+
+ expect(html).toContain(
+ 'Target ',
+ );
+ expect(html).toContain('id="targetSelect"');
+ expect(html).toMatch(/id="targetPickerStatus"[^>]*role="status"[^>]*aria-live="polite"/);
+ expect(html).toMatch(/id="targetStatusDot"[^>]*aria-hidden="true"/);
+ expect(html).toMatch(/id="performanceSection"[\S\s]*?role="tabpanel"[\S\s]*?hidden/);
+ expect(css).toMatch(/@media \(max-width: 480px\)[\S\s]*?\.target-picker-status\s*{[^}]*clip-path: inset\(50%\)/);
+ expect(css).not.toMatch(/\.target-picker-status\s*{[^}]*display:\s*none/);
+ });
+
+ it('tracks real property-editor focus events for refresh suppression', () => {
+ const harness = createPickerHarness(
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456',
+ );
+ const inspector = requiredPickerElement(harness, 'inspector');
+ const editor = createPickerStubElement('property-editor');
+ editor.closest = () => editor;
+
+ inspector.dispatch('focusin', { target: editor });
+
+ expect(harness.panel.state.componentPropertyEdit.focused).toBeTrue();
+ inspector.dispatch('focusout', { target: editor });
+ harness.runTimer(1);
+ expect(harness.panel.state.componentPropertyEdit.focused).toBeFalse();
+ });
+
+ it('keeps refresh suppressed when keyboard focus moves from an input to its Apply button', () => {
+ const harness = createPickerHarness(
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456',
+ );
+ const inspector = requiredPickerElement(harness, 'inspector');
+ const form = createPickerStubElement('property-form');
+ const input = createPickerStubElement('property-input');
+ const applyButton = createPickerStubElement('property-apply');
+ input.closest = selector =>
+ selector === '[data-component-property-editor]'
+ ? form
+ : selector === '[data-component-property-input]'
+ ? input
+ : null;
+ applyButton.closest = selector => (selector === '[data-component-property-editor]' ? form : null);
+
+ harness.setActiveElement(input);
+ inspector.dispatch('focusin', { target: input });
+ harness.setActiveElement(applyButton);
+ inspector.dispatch('focusout', { target: input });
+ inspector.dispatch('focusin', { target: applyButton });
+ harness.runTimer(1);
+
+ expect(harness.panel.state.componentPropertyEdit.focused).toBeTrue();
+ });
+
+ it('round-trips exact string scalars with accessible DOM controls and private authorization', async () => {
+ const harness = createPickerHarness(
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456',
+ );
+ const inspector = requiredPickerElement(harness, 'inspector');
+ const propertyName = ' line\r\n\0\uD800"[] ';
+ const value = 'first\r\nsecond\0\uD800"\\last';
+ const componentToken = 'd'.repeat(32);
+ const tree = componentTree();
+ const node = tree.children[0]?.children[0];
+ if (!node?.component) throw new Error('Expected the nested component fixture.');
+ node.component.properties = { [propertyName]: value };
+ node.component.propertyEdits = {
+ [propertyName]: { componentToken, snapshotRevision: 11 },
+ };
+ harness.panel.state.target = pickerTarget('owl:web-preview', {
+ capabilities: ['components', 'component-properties', 'component-property-edit', 'snapshot'],
+ identityMode: 'inspected-page',
+ platform: 'web',
+ sessionId: 'web-preview',
+ state: 'attached',
+ transport: 'web-preview',
+ });
+ harness.panel.state.snapshot = { tree };
+ harness.panel.state.snapshotGeneration = 11;
+ harness.panel.state.selectedNodeId = node.id;
+
+ const form = harness.panel.createComponentPropertyEditor(node, propertyName, value, {
+ componentToken,
+ snapshotRevision: 11,
+ });
+ form.closest = selector => (selector === '[data-component-property-editor]' ? form : null);
+ const editor = form.querySelector('[data-component-property-input]');
+ if (!editor) throw new Error('Expected the hydrated string property editor.');
+ const label = form.children[0];
+ const applyButton = form.children[1];
+ expect(form.innerHTML).toBe('');
+ expect(form.dataset).toEqual({ componentPropertyEditor: '' });
+ expect(editor.dataset).toEqual({ componentPropertyInput: '' });
+ expect(editor.value).toBe(JSON.stringify(value));
+ expect(editor.getAttribute('aria-label')).toBe(`Edit Valdi prop ${propertyName} as a JSON string literal`);
+ expect(label?.children[0]?.textContent).toBe(propertyName);
+ expect(applyButton?.getAttribute('aria-label')).toBe(`Apply Valdi prop ${propertyName}`);
+ expect(JSON.stringify(form)).not.toContain(componentToken);
+ expect(form.dataset['componentToken']).toBeUndefined();
+ expect(form.dataset['propertyName']).toBeUndefined();
+ expect(form.dataset['snapshotRevision']).toBeUndefined();
+
+ harness.queueResponse('/api/devtools/component-property', { updated: false });
+ inspector.dispatch('submit', { target: form });
+ await flushPickerPromises();
+
+ const request = harness.fetchRequests.find(
+ entry => new URL(entry.url).pathname === '/api/devtools/component-property',
+ );
+ if (!request?.body) throw new Error('Expected the exact string edit request.');
+ expect(JSON.parse(request.body)).toEqual({
+ componentId: node.id,
+ componentToken,
+ inspectedUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1',
+ propertyName,
+ protocolVersion: 1,
+ sessionId: 'web-preview',
+ snapshotRevision: 11,
+ targetNonce: 'panel-target-nonce-123456',
+ value,
+ });
+ });
+
+ it('serializes an invalidated poll and the edit-owned replacement without rotating the displayed token away', async () => {
+ const harness = createPickerHarness(
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456',
+ );
+ harness.panel.state.target = pickerTarget('owl:web-preview', {
+ capabilities: ['components', 'component-properties', 'component-property-edit', 'snapshot'],
+ identityMode: 'inspected-page',
+ platform: 'web',
+ sessionId: 'web-preview',
+ state: 'attached',
+ transport: 'web-preview',
+ });
+ harness.panel.state.snapshot = { tree: editableComponentTree(true, 'a'.repeat(32), 7) };
+ harness.panel.state.snapshotGeneration = 7;
+ harness.panel.state.selectedNodeId = 'component:["7","nested"]';
+
+ const invalidatedPoll = harness.queueDeferred('/api/devtools/snapshot');
+ const invalidatedPollRequest = harness.panel.refreshSnapshot();
+ await flushPickerPromises();
+ expect(harness.panel.state.refreshPending).toBeTrue();
+ expect(harness.fetchRequests.filter(request => request.url.includes('/api/devtools/snapshot')).length).toBe(1);
+
+ const editResponse = harness.queueDeferred('/api/devtools/component-property');
+ const ownedReplacement = harness.queueDeferred('/api/devtools/snapshot');
+ harness.panel.state.componentPropertyEdit.focused = true;
+ const edit = harness.panel.submitComponentPropertyEdit(
+ 'component:["7","nested"]',
+ 'enabled',
+ 'a'.repeat(32),
+ 7,
+ false,
+ );
+ await flushPickerPromises();
+ const editRequest = harness.fetchRequests.find(request => request.url.includes('/api/devtools/component-property'));
+ if (!editRequest?.body) throw new Error('Expected the serialized component property edit.');
+ const editBody = JSON.parse(editRequest.body) as { componentToken?: unknown };
+ expect(editBody.componentToken).toBe('a'.repeat(32));
+ expect(harness.panel.state.refreshPending).toBeTrue();
+
+ editResponse.resolve({ updated: true });
+ await flushPickerPromises();
+ expect(harness.fetchRequests.filter(request => request.url.includes('/api/devtools/snapshot')).length).toBe(1);
+ expect(harness.panel.state.componentPropertyEdit.pending).toBeTrue();
+
+ invalidatedPoll.resolve({ tree: editableComponentTree(false, 'b'.repeat(32), 8) });
+ await invalidatedPollRequest;
+ await flushPickerPromises();
+ expect(
+ harness.panel.state.snapshot?.tree.children[0]?.children[0]?.component?.propertyEdits?.['enabled']
+ ?.snapshotRevision,
+ ).toBe(7);
+ expect(harness.fetchRequests.filter(request => request.url.includes('/api/devtools/snapshot')).length).toBe(2);
+ expect(harness.panel.state.refreshPending).toBeTrue();
+
+ harness.panel.state.componentPropertyEdit.focused = true;
+ void harness.panel.refreshSnapshot();
+ await flushPickerPromises();
+ expect(harness.fetchRequests.filter(request => request.url.includes('/api/devtools/snapshot')).length).toBe(2);
+
+ ownedReplacement.resolve({ tree: editableComponentTree(false, 'c'.repeat(32), 9) });
+ await edit;
+ expect(harness.panel.state.componentPropertyEdit.pending).toBeFalse();
+ expect(harness.panel.state.componentPropertyEdit.focused).toBeFalse();
+ expect(harness.panel.state.refreshPending).toBeFalse();
+ expect(harness.panel.state.snapshot?.tree.children[0]?.children[0]?.component?.propertyEdits?.['enabled']).toEqual({
+ componentToken: 'c'.repeat(32),
+ snapshotRevision: 9,
+ });
+ expect(harness.fetchRequests.filter(request => request.url.includes('/api/devtools/snapshot')).length).toBe(2);
+
+ const ordinaryPoll = harness.queueDeferred('/api/devtools/snapshot');
+ const ordinaryPollRequest = harness.panel.refreshSnapshot();
+ await flushPickerPromises();
+ expect(harness.fetchRequests.filter(request => request.url.includes('/api/devtools/snapshot')).length).toBe(3);
+ ordinaryPoll.resolve({ tree: editableComponentTree(false, 'd'.repeat(32), 10) });
+ await ordinaryPollRequest;
+ expect(harness.panel.state.snapshot?.tree.children[0]?.children[0]?.component?.propertyEdits?.['enabled']).toEqual({
+ componentToken: 'd'.repeat(32),
+ snapshotRevision: 10,
+ });
+ });
+
+ it('defers an edit-owned snapshot while either State surface owns focus', async () => {
+ for (const focusScope of ['main', 'inspector'] as const) {
+ const harness = createPickerHarness(
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456',
+ );
+ harness.panel.state.target = pickerTarget('owl:web-preview', {
+ capabilities: ['components', 'component-properties', 'component-property-edit', 'snapshot'],
+ identityMode: 'inspected-page',
+ platform: 'web',
+ sessionId: 'web-preview',
+ state: 'attached',
+ transport: 'web-preview',
+ });
+ harness.panel.state.snapshot = { tree: editableComponentTree(true, 'a'.repeat(32), 7) };
+ harness.panel.state.snapshotGeneration = 7;
+ harness.panel.state.selectedNodeId = 'component:["7","nested"]';
+ const editResponse = harness.queueDeferred('/api/devtools/component-property');
+ const edit = harness.panel.submitComponentPropertyEdit(
+ 'component:["7","nested"]',
+ 'enabled',
+ 'a'.repeat(32),
+ 7,
+ false,
+ );
+ await flushPickerPromises();
+ expect(harness.panel.state.componentPropertyEdit.pending).withContext(focusScope).toBeTrue();
+
+ const focusedControl = createPickerStubElement(`${focusScope}-state-control`);
+ const stateSection = requiredPickerElement(harness, 'stateSection');
+ const inspector = requiredPickerElement(harness, 'inspector');
+ stateSection.contains = () => focusScope === 'main';
+ inspector.contains = () => focusScope === 'inspector';
+ harness.panel.state.activeDetail = focusScope === 'inspector' ? 'state' : 'styles';
+ harness.setActiveElement(focusedControl);
+ const focusedMarkup = `focused-${focusScope}-state`;
+ inspector.innerHTML = focusedMarkup;
+
+ editResponse.resolve({ updated: true });
+ await edit;
+
+ expect(harness.fetchRequests.filter(request => new URL(request.url).pathname === '/api/devtools/snapshot').length)
+ .withContext(focusScope)
+ .toBe(0);
+ expect(harness.panel.state.componentPropertyEdit.pending).withContext(focusScope).toBeFalse();
+ expect(harness.panel.state.componentPropertyEdit.error).withContext(focusScope).toBeNull();
+ expect(harness.panel.state.snapshotGeneration).withContext(focusScope).toBe(7);
+ expect(inspector.innerHTML).withContext(focusScope).toBe(focusedMarkup);
+
+ harness.setActiveElement(null);
+ harness.queueResponse('/api/devtools/snapshot', {
+ tree: editableComponentTree(false, 'b'.repeat(32), 8),
+ });
+ await harness.panel.refreshSnapshot();
+
+ expect(harness.panel.state.snapshotGeneration).withContext(focusScope).toBe(8);
+ expect(
+ harness.panel.state.snapshot?.tree.children[0]?.children[0]?.component?.propertyEdits?.['enabled']
+ ?.snapshotRevision,
+ )
+ .withContext(focusScope)
+ .toBe(8);
+ }
+ });
+
+ it('rejects an empty numeric editor value through the form submit path', () => {
+ const harness = createPickerHarness(
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456',
+ );
+ const inspector = requiredPickerElement(harness, 'inspector');
+ const node = componentTree().children[0]?.children[0];
+ if (!node?.component) throw new Error('Expected the nested component fixture.');
+ const form = harness.panel.createComponentPropertyEditor(node, 'count', 1, {
+ componentToken: 'a'.repeat(32),
+ snapshotRevision: 1,
+ });
+ form.closest = selector => (selector === '[data-component-property-editor]' ? form : null);
+ const editor = form.querySelector('[data-component-property-input]');
+ if (!editor) throw new Error('Expected the numeric property editor.');
+ editor.value = ' ';
+
+ inspector.dispatch('submit', { target: form });
+
+ expect(harness.panel.state.componentPropertyEdit.error).toBe(
+ 'Enter a valid scalar value before applying this property.',
+ );
+ expect(harness.fetchRequests).toEqual([]);
+ });
+
+ it('rejects mixed, partial, empty, and duplicated launch identities without making a request', async () => {
+ const invalidSearches = [
+ '?targetId=direct&inspectedUrl=http%3A%2F%2F127.0.0.1%3A1234%2F&targetNonce=nonce',
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A1234%2F',
+ '?targetNonce=nonce',
+ '?targetId=',
+ '?targetId=one&targetId=two',
+ '?inspectedUrl=one&inspectedUrl=two&targetNonce=nonce',
+ ];
+
+ for (const search of invalidSearches) {
+ const harness = createPickerHarness(search);
+ await harness.panel.connectToInspectedApplication();
+ expect(harness.fetchRequests).withContext(search).toEqual([]);
+ expect(harness.panel.state.error)
+ .withContext(search)
+ .toMatch(/identity|requires|targetId/);
+ }
+ });
+
+ it('preserves inspected-page mode and never discovers the target registry', async () => {
+ const harness = createPickerHarness(
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456',
+ );
+ harness.queueResponse('/api/devtools/target', {
+ target: {
+ applicationUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1',
+ debuggingPort: 9222,
+ id: 'owl:web-preview',
+ name: 'index.html',
+ sessionId: 'web-preview',
+ },
+ });
+ harness.queueResponse('/api/devtools/snapshot', { tree: componentTree() });
+
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+
+ const paths = harness.fetchRequests.map(request => new URL(request.url).pathname);
+ expect(paths).toContain('/api/devtools/target');
+ expect(paths).toContain('/api/devtools/snapshot');
+ expect(paths).not.toContain('/api/devtools/targets');
+ const snapshotRequest = harness.fetchRequests.find(request => request.url.includes('/snapshot'));
+ if (!snapshotRequest) throw new Error('Expected an inspected-page snapshot request.');
+ const snapshotUrl = new URL(snapshotRequest.url);
+ expect(Object.fromEntries(snapshotUrl.searchParams)).toEqual({
+ inspectedUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1',
+ sessionId: 'web-preview',
+ targetNonce: 'panel-target-nonce-123456',
+ });
+ expect(requiredPickerElement(harness, 'targetSelect').hidden).toBeTrue();
+ });
+
+ it('clears target-owned Console state when an inspected-page session is replaced', async () => {
+ const harness = createPickerHarness(
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456',
+ );
+ harness.queueResponse('/api/devtools/target', {
+ target: {
+ applicationUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1',
+ debuggingPort: 9222,
+ id: 'owl:web-preview',
+ name: 'index.html',
+ sessionId: 'web-preview',
+ },
+ });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ harness.panel.state.consoleEntries.push({ kind: 'log', value: 'old session output' });
+ harness.panel.state.consoleEntryKeys.add('old-session-entry');
+ harness.panel.state.consoleHistory.push('oldSessionExpression()');
+ requiredPickerElement(harness, 'consoleInput').value = 'old draft';
+ harness.queueResponse('/api/devtools/target', {
+ target: {
+ applicationUrl: 'http://127.0.0.1:54321/replacement.html?valdiDevTools=1',
+ debuggingPort: 9222,
+ id: 'owl:replacement',
+ name: 'replacement.html',
+ sessionId: 'replacement',
+ },
+ });
+
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+
+ expect(harness.panel.state.target?.id).toBe('owl:replacement');
+ expect(harness.panel.state.consoleEntries.some(entry => entry.value === 'old session output')).toBeFalse();
+ expect(harness.panel.state.consoleEntryKeys.has('old-session-entry')).toBeFalse();
+ expect(harness.panel.state.consoleHistory).toEqual([]);
+ expect(requiredPickerElement(harness, 'consoleInput').value).toBe('');
+ });
+
+ it('rerenders cleared Performance state after an inspected-page replacement with Live off', async () => {
+ const harness = createPickerHarness(
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456',
+ );
+ harness.queueResponse('/api/devtools/target', {
+ target: {
+ applicationUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1',
+ debuggingPort: 9222,
+ id: 'owl:web-preview',
+ name: 'index.html',
+ sessionId: 'web-preview',
+ },
+ });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ const liveToggle = requiredPickerElement(harness, 'autoRefreshToggle');
+ liveToggle.checked = false;
+ liveToggle.dispatch('change');
+ harness.panel.state.performance.data = {
+ mainThread: { layoutDurationMs: 2, scriptDurationMs: 4, taskDurationMs: 12 },
+ memory: { usedBytes: 2048 },
+ resourceCount: 4,
+ uptimeMs: 100,
+ };
+ harness.panel.state.performance.pending = true;
+ harness.panel.setActiveSection('performance');
+ expect(requiredPickerElement(harness, 'performanceContent').innerHTML).toContain('JS heap');
+ harness.queueResponse('/api/devtools/target', {
+ target: {
+ applicationUrl: 'http://127.0.0.1:54321/replacement.html?valdiDevTools=1',
+ debuggingPort: 9222,
+ id: 'owl:replacement',
+ name: 'replacement.html',
+ sessionId: 'replacement',
+ },
+ });
+
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+
+ expect(harness.panel.state.performance.data).toBeNull();
+ expect(harness.panel.state.performance.pending).toBeFalse();
+ expect(requiredPickerElement(harness, 'performanceContent').innerHTML).not.toContain('JS heap');
+ });
+
+ it('renders bounded unavailable targets truthfully and never selects the first registry entry', async () => {
+ const harness = createPickerHarness('?targetId=missing-target');
+ const longUnsafeName = ` ${'x'.repeat(300)}`;
+ harness.queueResponse('/api/devtools/targets', {
+ targets: [
+ pickerTarget('available-target', { name: longUnsafeName }),
+ pickerTarget('web-preview', {
+ identityMode: 'inspected-page',
+ name: 'Web preview',
+ transport: 'chromium-cdp',
+ }),
+ pickerTarget('waiting-proxy', {
+ attachable: true,
+ capabilities: ['components', 'snapshot'],
+ identityMode: 'target-id',
+ name: 'Waiting proxy',
+ state: 'waiting',
+ transport: 'valdi-daemon',
+ }),
+ ],
+ });
+
+ await harness.panel.connectToInspectedApplication();
+
+ expect(harness.panel.state.target).toBeNull();
+ expect(harness.fetchRequests.filter(request => request.url.includes('/snapshot'))).toEqual([]);
+ const options = requiredPickerElement(harness, 'targetSelect').children;
+ const available = options.find(option => option.value === 'available-target');
+ const web = options.find(option => option.value === 'web-preview');
+ const waiting = options.find(option => option.value === 'waiting-proxy');
+ const missing = options.find(option => option.value === 'missing-target');
+ expect(available?.selected).toBeFalse();
+ expect(available?.textContent.length).toBeLessThanOrEqual(180);
+ expect(available?.textContent).toContain(' ');
+ expect(available?.innerHTML).toBe('');
+ expect(web?.disabled).toBeTrue();
+ expect(web?.textContent).toContain('Open from the inspected page');
+ expect(waiting?.disabled).toBeTrue();
+ expect(waiting?.textContent).toContain('Waiting for application');
+ expect(missing?.disabled).toBeTrue();
+ expect(requiredPickerElement(harness, 'targetPickerStatus').textContent).toContain('unavailable');
+ });
+
+ it('fails closed on oversized, duplicated, and malformed registries', () => {
+ const harness = createPickerHarness('?targetId=requested');
+ const oversized = Array.from({ length: 257 }, (_value, index) => pickerTarget(`target-${index}`));
+
+ for (const [payload, expectedMessage] of [
+ [{ targets: oversized }, 'exceeded 256 entries'],
+ [{ targets: [pickerTarget('duplicate'), pickerTarget('duplicate')] }, 'duplicate target IDs'],
+ [{ targets: [{ ...pickerTarget('bad-capability'), capabilities: ['snapshot', 'snapshot'] }] }, 'malformed entry'],
+ [{ targets: [{ ...pickerTarget('bad-port'), port: 0 }] }, 'malformed entry'],
+ ] as Array<[Record, string]>) {
+ let message = '';
+ try {
+ harness.panel.parseTargetRegistry(payload);
+ } catch (error) {
+ message = error instanceof Error ? error.message : String(error);
+ }
+ expect(message).toContain(expectedMessage);
+ }
+ });
+
+ it('selects only the exact requested opaque ID and sends targetId alone for direct snapshots', async () => {
+ const harness = createPickerHarness('?targetId=opaque%3Arequested%2Ftarget');
+ harness.queueResponse('/api/devtools/targets', {
+ targets: [pickerTarget('first-target'), pickerTarget('opaque:requested/target')],
+ });
+ harness.queueResponse('/api/devtools/snapshot', { tree: componentTree() });
+
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+
+ expect(harness.panel.state.target?.id).toBe('opaque:requested/target');
+ const snapshotRequest = harness.fetchRequests.find(request => request.url.includes('/api/devtools/snapshot'));
+ if (!snapshotRequest) throw new Error('Expected a direct snapshot request.');
+ const snapshotUrl = new URL(snapshotRequest.url);
+ expect(Array.from(snapshotUrl.searchParams.keys())).toEqual(['targetId']);
+ expect(snapshotUrl.searchParams.get('targetId')).toBe('opaque:requested/target');
+ expect(harness.fetchRequests.some(request => request.url.includes('/api/devtools/target?'))).toBeFalse();
+ });
+
+ it('clears all target-owned presentation and closes Console before an idle user switch', async () => {
+ const harness = createPickerHarness('?targetId=target-a');
+ const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'console'] });
+ const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'console'] });
+ harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+
+ harness.panel.state.consoleEntries.push({ kind: 'log', value: 'old output' });
+ harness.panel.state.consoleEntryKeys.add('old');
+ harness.panel.state.consoleHistory.push('oldExpression()');
+ harness.panel.state.expandedNodeIds.add('7');
+ harness.panel.state.selectedNodeId = '7';
+ harness.panel.state.performance.data = { uptimeMs: 1 };
+ harness.panel.state.performance.lastTrace = { traceCount: 1 };
+ harness.panel.state.performance.samples.push({ uptimeMs: 1 });
+ const select = requiredPickerElement(harness, 'targetSelect');
+ select.value = 'target-b';
+ select.dispatch('change');
+
+ expect(harness.closeTargetIds).toContain('target-a');
+ expect(harness.panel.state.target?.id).toBe('target-b');
+ expect(harness.panel.state.snapshot).toBeNull();
+ expect(harness.panel.state.selectedNodeId).toBeNull();
+ expect(harness.panel.state.expandedNodeIds.size).toBe(0);
+ expect(harness.panel.state.consoleEntries).toEqual([]);
+ expect(harness.panel.state.consoleEntryKeys.size).toBe(0);
+ expect(harness.panel.state.consoleHistory).toEqual([]);
+ expect(harness.panel.state.performance.data).toBeNull();
+ expect(harness.panel.state.performance.lastTrace).toBeNull();
+ expect(harness.panel.state.performance.samples).toEqual([]);
+ });
+
+ it('blocks pending and owned Performance switches and restores the selected option', async () => {
+ const harness = createPickerHarness('?targetId=target-a');
+ const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'performance'] });
+ const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'performance'] });
+ harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ const select = requiredPickerElement(harness, 'targetSelect');
+
+ harness.panel.state.performance.pending = true;
+ select.value = 'target-b';
+ select.dispatch('change');
+ expect(harness.panel.state.target?.id).toBe('target-a');
+ expect(select.value).toBe('target-a');
+ expect(requiredPickerElement(harness, 'targetPickerStatus').textContent).toContain('before switching');
+
+ harness.panel.state.performance.pending = false;
+ harness.panel.state.performance.traceActive = true;
+ harness.panel.state.performance.ownerIdentity = { targetId: 'target-a' };
+ select.value = 'target-b';
+ select.dispatch('change');
+ expect(harness.panel.state.target?.id).toBe('target-a');
+ expect(select.value).toBe('target-a');
+ });
+
+ it('detaches on registry removal without fallback and keeps exact-owner Stop reachable', async () => {
+ const harness = createPickerHarness('?targetId=target-a');
+ const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'performance'] });
+ const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'performance'] });
+ harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ harness.panel.setActiveSection('performance');
+ harness.panel.state.performance.traceActive = true;
+ harness.panel.state.performance.ownerIdentity = { targetId: 'target-a' };
+ harness.queueResponse('/api/devtools/targets', { targets: [targetB] });
+
+ await harness.panel.refreshTargetRegistry();
+
+ expect(harness.panel.state.target).toBeNull();
+ expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-a' });
+ expect(requiredPickerElement(harness, 'performanceTab').disabled).toBeFalse();
+ expect(harness.panel.state.activeSection).toBe('performance');
+ harness.queueResponse('/api/devtools/performance/trace/stop', { traceCount: 0, traces: [] });
+ await harness.panel.runPerformanceAction('trace-stop');
+ const stopRequest = harness.fetchRequests.find(request => request.url.includes('/trace/stop'));
+ if (!stopRequest) throw new Error('Expected an exact-owner Performance stop.');
+ expect(new URL(stopRequest.url).searchParams.get('targetId')).toBe('target-a');
+ expect(harness.panel.state.performance.ownerIdentity).toBeNull();
+ expect(requiredPickerElement(harness, 'performanceTab').disabled).toBeTrue();
+ expect(harness.panel.state.activeSection).toBe('elements');
+ });
+
+ it('renders exact-owner recovery without stale metrics when removal interrupts a capture offscreen', async () => {
+ const harness = createPickerHarness('?targetId=target-a');
+ const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'performance'] });
+ const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'performance'] });
+ harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ harness.panel.state.performance.data = {
+ mainThread: { layoutDurationMs: 2, scriptDurationMs: 4, taskDurationMs: 12 },
+ memory: { usedBytes: 2048 },
+ resourceCount: 4,
+ uptimeMs: 100,
+ };
+ const oldCapture = harness.queueDeferred('/api/devtools/performance/trace/capture');
+ const oldCaptureAction = harness.panel.runPerformanceAction('trace-capture');
+ await flushPickerPromises();
+ expect(harness.panel.state.activeSection).toBe('elements');
+ expect(harness.panel.state.performance.pending).toBeTrue();
+ expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-a' });
+ expect(requiredPickerElement(harness, 'performanceContent').innerHTML).toContain('JS heap');
+ harness.queueResponse('/api/devtools/targets', { targets: [targetB] });
+
+ await harness.panel.refreshTargetRegistry();
+ harness.panel.setActiveSection('performance');
+
+ const recoveryContent = requiredPickerElement(harness, 'performanceContent').innerHTML;
+ expect(harness.panel.state.target).toBeNull();
+ expect(harness.panel.state.performance.data).toBeNull();
+ expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-a' });
+ expect(recoveryContent).toContain('Stop and retrieve');
+ expect(recoveryContent).toContain('data-performance-action="trace-stop"');
+ expect(recoveryContent).not.toContain('data-performance-action="trace-stop" disabled');
+ expect(recoveryContent).not.toContain('JS heap');
+
+ oldCapture.resolve({ traceCount: 1, traces: [] });
+ await oldCaptureAction;
+ expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-a' });
+ expect(harness.panel.state.performance.pending).toBeFalse();
+ });
+
+ it('does not let an old owner Stop result or finally clear a newer recovery Stop', async () => {
+ const harness = createPickerHarness('?targetId=target-a');
+ const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'performance'] });
+ const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'performance'] });
+ harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ harness.panel.state.performance.traceActive = true;
+ harness.panel.state.performance.ownerIdentity = { targetId: 'target-a' };
+ const oldStop = harness.queueDeferred('/api/devtools/performance/trace/stop');
+ const oldStopAction = harness.panel.runPerformanceAction('trace-stop');
+ await flushPickerPromises();
+ harness.queueResponse('/api/devtools/targets', { targets: [targetB] });
+ await harness.panel.refreshTargetRegistry();
+ const recoveryStop = harness.queueDeferred('/api/devtools/performance/trace/stop');
+ const recoveryStopAction = harness.panel.runPerformanceAction('trace-stop');
+ await flushPickerPromises();
+ harness.panel.state.performance.lastTrace = { marker: 'newer recovery state' };
+
+ oldStop.resolve({ marker: 'old stop result', traceCount: 1, traces: [] });
+ await oldStopAction;
+
+ expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-a' });
+ expect(harness.panel.state.performance.traceActive).toBeTrue();
+ expect(harness.panel.state.performance.pending).toBeTrue();
+ expect(harness.panel.state.performance.lastTrace).toEqual({ marker: 'newer recovery state' });
+ expect(harness.panel.state.performance.error).toBeNull();
+
+ recoveryStop.resolve({ traceCount: 2, traces: [] });
+ await recoveryStopAction;
+ expect(harness.panel.state.performance.ownerIdentity).toBeNull();
+ expect(harness.panel.state.performance.pending).toBeFalse();
+ });
+
+ it('does not let an old capture error or finally overwrite a new target capture', async () => {
+ const harness = createPickerHarness('?targetId=target-a');
+ const targetA = pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'performance'] });
+ const targetB = pickerTarget('target-b', { capabilities: ['components', 'snapshot', 'performance'] });
+ harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ const oldCapture = harness.queueDeferred('/api/devtools/performance/trace/capture');
+ const oldCaptureAction = harness.panel.runPerformanceAction('trace-capture');
+ await flushPickerPromises();
+ harness.queueResponse('/api/devtools/targets', { targets: [targetB] });
+ await harness.panel.refreshTargetRegistry();
+ harness.queueResponse('/api/devtools/performance/trace/stop', { traceCount: 0, traces: [] });
+ await harness.panel.runPerformanceAction('trace-stop');
+ const select = requiredPickerElement(harness, 'targetSelect');
+ select.value = 'target-b';
+ select.dispatch('change');
+ const newCapture = harness.queueDeferred('/api/devtools/performance/trace/capture');
+ const newCaptureAction = harness.panel.runPerformanceAction('trace-capture');
+ await flushPickerPromises();
+ harness.panel.state.performance.data = { marker: 'new target metrics' };
+ harness.panel.state.performance.lastTrace = { marker: 'new target trace' };
+ const warn = spyOn(console, 'warn');
+
+ oldCapture.reject(new Error('old capture failure'));
+ await oldCaptureAction;
+
+ expect(harness.panel.state.performance.ownerIdentity).toEqual({ targetId: 'target-b' });
+ expect(harness.panel.state.performance.traceActive).toBeTrue();
+ expect(harness.panel.state.performance.pending).toBeTrue();
+ expect(harness.panel.state.performance.error).toBeNull();
+ expect(harness.panel.state.performance.data).toEqual({ marker: 'new target metrics' });
+ expect(harness.panel.state.performance.lastTrace).toEqual({ marker: 'new target trace' });
+ expect(warn).toHaveBeenCalledWith('Ignoring a stale web preview performance action error.', jasmine.anything());
+
+ newCapture.resolve({ traceCount: 3, traces: [] });
+ await newCaptureAction;
+ expect(harness.panel.state.performance.ownerIdentity).toBeNull();
+ expect(harness.panel.state.performance.pending).toBeFalse();
+ });
+
+ it('keeps a replacement snapshot pending when an older target resolves or rejects', async () => {
+ const harness = createPickerHarness('?targetId=target-a');
+ const targetA = pickerTarget('target-a');
+ const targetB = pickerTarget('target-b');
+ harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+
+ const oldSnapshot = harness.queueDeferred('/api/devtools/snapshot');
+ void harness.panel.refreshSnapshot();
+ const replacementSnapshot = harness.queueDeferred('/api/devtools/snapshot');
+ const select = requiredPickerElement(harness, 'targetSelect');
+ select.value = 'target-b';
+ select.dispatch('change');
+ oldSnapshot.resolve({ tree: componentTree() });
+ await flushPickerPromises();
+ expect(harness.panel.state.target?.id).toBe('target-b');
+ expect(harness.panel.state.snapshot).toBeNull();
+ expect(harness.panel.state.refreshPending).toBeTrue();
+ replacementSnapshot.resolve({ tree: componentTree() });
+ await flushPickerPromises();
+ expect(harness.panel.state.refreshPending).toBeFalse();
+
+ const staleError = harness.queueDeferred('/api/devtools/snapshot');
+ void harness.panel.refreshSnapshot();
+ const finalSnapshot = harness.queueDeferred('/api/devtools/snapshot');
+ select.value = 'target-a';
+ select.dispatch('change');
+ staleError.reject(new Error('stale target failure'));
+ await flushPickerPromises();
+ expect(harness.panel.state.error).toBeNull();
+ expect(harness.panel.state.refreshPending).toBeTrue();
+ finalSnapshot.resolve({ tree: componentTree() });
+ await flushPickerPromises();
+ });
+
+ it('orders an exact old-target highlight clear before new-target highlight work', async () => {
+ const harness = createPickerHarness('?targetId=target-a');
+ const capabilities = ['components', 'snapshot', 'highlight'];
+ const targetA = pickerTarget('target-a', { capabilities });
+ const targetB = pickerTarget('target-b', { capabilities });
+ harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ const oldHighlight = harness.queueDeferred('/api/devtools/highlight');
+
+ harness.panel.queueHighlight('7');
+ if (harness.panel.state.highlightTimer === null) throw new Error('Expected a highlight timer.');
+ harness.runTimer(harness.panel.state.highlightTimer);
+ await flushPickerPromises();
+ const select = requiredPickerElement(harness, 'targetSelect');
+ select.value = 'target-b';
+ select.dispatch('change');
+ expect(harness.fetchRequests.filter(request => request.url.includes('/highlight')).length).toBe(1);
+
+ oldHighlight.resolve({ highlighted: true });
+ await harness.panel.state.highlightRequestTail;
+ const highlightRequests = harness.fetchRequests.filter(request => request.url.includes('/highlight'));
+ expect(highlightRequests.length).toBe(2);
+ const finalHighlightRequest = highlightRequests[1];
+ if (finalHighlightRequest?.body === undefined) throw new Error('Expected a serialized old-target highlight clear.');
+ expect(JSON.parse(finalHighlightRequest.body)).toEqual({ targetId: 'target-a' });
+ });
+
+ it('drops old Console streams and evaluation success or failure after a switch', async () => {
+ const harness = createPickerHarness('?targetId=target-a');
+ const capabilities = ['components', 'snapshot', 'console'];
+ const targetA = pickerTarget('target-a', { capabilities });
+ const targetB = pickerTarget('target-b', { capabilities });
+ harness.queueResponse('/api/devtools/targets', { targets: [targetA, targetB] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ const oldStream = harness.eventSources[0];
+ if (!oldStream) throw new Error('Expected a Console stream.');
+ const oldEvaluation = harness.queueDeferred('/api/devtools/evaluate');
+ void harness.panel.evaluateConsoleExpression('oldValue()');
+ const select = requiredPickerElement(harness, 'targetSelect');
+ select.value = 'target-b';
+ select.dispatch('change');
+
+ oldStream.emit('console', { level: 'log', message: 'stale log', targetId: 'target-a' });
+ oldStream.emit('stream-warning', { message: 'stale warning', targetId: 'target-a' });
+ oldStream.emit('stream-error', { error: 'stale error', targetId: 'target-a' });
+ oldEvaluation.resolve({ type: 'string', value: 'stale result' });
+ await flushPickerPromises();
+ expect(harness.panel.state.consoleEntries).toEqual([]);
+
+ const staleFailure = harness.queueDeferred('/api/devtools/evaluate');
+ void harness.panel.evaluateConsoleExpression('secondOldValue()');
+ select.value = 'target-a';
+ select.dispatch('change');
+ staleFailure.reject(new Error('stale evaluation failure'));
+ await flushPickerPromises();
+ expect(harness.panel.state.consoleEntries).toEqual([]);
+ });
+
+ it('gates unsupported tabs and generates no Console or Performance traffic for component-only targets', async () => {
+ const harness = createPickerHarness('?targetId=components-only');
+ harness.queueResponse('/api/devtools/targets', { targets: [pickerTarget('components-only')] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+
+ expect(requiredPickerElement(harness, 'consoleTab').disabled).toBeTrue();
+ expect(requiredPickerElement(harness, 'consoleTab').getAttribute('aria-disabled')).toBe('true');
+ expect(requiredPickerElement(harness, 'performanceTab').disabled).toBeTrue();
+ expect(harness.eventSources).toEqual([]);
+ harness.panel.setActiveSection('console');
+ expect(harness.panel.state.activeSection).toBe('elements');
+ harness.panel.setActiveSection('performance');
+ expect(harness.panel.state.activeSection).toBe('elements');
+ await harness.panel.evaluateConsoleExpression('shouldNotRun()');
+ expect(
+ harness.fetchRequests.some(request =>
+ ['/api/devtools/evaluate', '/api/devtools/console/stream', '/api/devtools/performance'].some(pathname =>
+ request.url.includes(pathname),
+ ),
+ ),
+ ).toBeFalse();
+ });
+
+ it('uses roving tab focus and skips capability-disabled tools during keyboard navigation', async () => {
+ const harness = createPickerHarness('?targetId=console-target');
+ harness.queueResponse('/api/devtools/targets', {
+ targets: [pickerTarget('console-target', { capabilities: ['components', 'snapshot', 'console'] })],
+ });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ harness.panel.setActiveSection('elements');
+ const elementsTab = requiredPickerElement(harness, 'elementsTab');
+ const stateTab = requiredPickerElement(harness, 'stateTab');
+ const performanceTab = requiredPickerElement(harness, 'performanceTab');
+ const consoleTab = requiredPickerElement(harness, 'consoleTab');
+
+ elementsTab.dispatch('keydown', { key: 'ArrowRight' });
+
+ expect(performanceTab.disabled).toBeTrue();
+ expect(harness.panel.state.activeSection).toBe('state');
+ expect(elementsTab.tabIndex).toBe(-1);
+ expect(stateTab.tabIndex).toBe(0);
+ expect(stateTab.getAttribute('aria-selected')).toBe('true');
+ expect(requiredPickerElement(harness, 'elementsSection').hidden).toBeTrue();
+ expect(requiredPickerElement(harness, 'stateSection').hidden).toBeFalse();
+
+ stateTab.dispatch('keydown', { key: 'ArrowRight' });
+ expect(harness.panel.state.activeSection).toBe('console');
+ consoleTab.dispatch('keydown', { key: 'ArrowRight' });
+ expect(harness.panel.state.activeSection).toBe('elements');
+ });
+
+ it('invalidates streams and active tabs when capabilities change for the same target ID', async () => {
+ const harness = createPickerHarness('?targetId=target-a');
+ const consoleTarget = pickerTarget('target-a', {
+ capabilities: ['components', 'snapshot', 'console', 'performance'],
+ });
+ harness.queueResponse('/api/devtools/targets', { targets: [consoleTarget] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ const oldGeneration = harness.panel.state.targetGeneration;
+ const oldStream = harness.eventSources[0];
+ if (!oldStream) throw new Error('Expected a Console stream.');
+ harness.panel.state.consoleEntries.push({ kind: 'log', value: 'old capability output' });
+ harness.panel.state.consoleEntryKeys.add('old-capability-entry');
+ harness.panel.state.consoleHistory.push('oldCapabilityExpression()');
+ harness.panel.state.expandedNodeIds.add('7');
+ harness.panel.state.selectedNodeId = '7';
+ harness.panel.state.performance.data = { uptimeMs: 1 };
+ harness.panel.state.performance.lastTrace = { traceCount: 1 };
+ harness.panel.state.performance.samples.push({ uptimeMs: 1 });
+ harness.panel.setActiveSection('console');
+ const replacementSnapshot = harness.queueDeferred('/api/devtools/snapshot');
+ harness.queueResponse('/api/devtools/targets', {
+ targets: [pickerTarget('target-a', { capabilities: ['components', 'snapshot', 'console,performance'] })],
+ });
+
+ await harness.panel.refreshTargetRegistry();
+
+ expect(harness.panel.state.target?.id).toBe('target-a');
+ expect(harness.panel.state.targetGeneration).toBeGreaterThan(oldGeneration);
+ expect(oldStream.closed).toBeTrue();
+ expect(harness.panel.state.selectedNodeId).toBeNull();
+ expect(harness.panel.state.expandedNodeIds.size).toBe(0);
+ expect(harness.panel.state.consoleEntries).toEqual([]);
+ expect(harness.panel.state.consoleEntryKeys.size).toBe(0);
+ expect(harness.panel.state.consoleHistory).toEqual([]);
+ expect(harness.panel.state.performance.data).toBeNull();
+ expect(harness.panel.state.performance.lastTrace).toBeNull();
+ expect(harness.panel.state.performance.samples).toEqual([]);
+ expect(requiredPickerElement(harness, 'consoleTab').disabled).toBeTrue();
+ expect(harness.panel.state.activeSection).toBe('elements');
+ oldStream.emit('console', { level: 'log', message: 'stale same-id log', targetId: 'target-a' });
+ expect(harness.panel.state.consoleEntries).toEqual([]);
+ replacementSnapshot.resolve({ tree: componentTree() });
+ await flushPickerPromises();
+ });
+
+ it('keeps the exact current target when a later registry payload is malformed', async () => {
+ const harness = createPickerHarness('?targetId=target-a');
+ harness.queueResponse('/api/devtools/targets', { targets: [pickerTarget('target-a'), pickerTarget('target-b')] });
+ await harness.panel.connectToInspectedApplication();
+ await flushPickerPromises();
+ const targetGeneration = harness.panel.state.targetGeneration;
+ harness.queueResponse('/api/devtools/targets', {
+ targets: [pickerTarget('duplicate'), pickerTarget('duplicate')],
+ });
+
+ await harness.panel.refreshTargetRegistry();
+
+ expect(harness.panel.state.target?.id).toBe('target-a');
+ expect(harness.panel.state.targetGeneration).toBe(targetGeneration);
+ expect(requiredPickerElement(harness, 'targetSelect').value).toBe('target-a');
+ expect(requiredPickerElement(harness, 'targetPickerStatus').textContent).toContain('duplicate target IDs');
+ });
+
+ it('lets only the newest registry generation publish and still does not auto-select', async () => {
+ const harness = createPickerHarness('?targetId=requested-target');
+ const first = harness.queueDeferred('/api/devtools/targets');
+ void harness.panel.refreshTargetRegistry();
+ harness.panel.state.registryPending = false;
+ const second = harness.queueDeferred('/api/devtools/targets');
+ void harness.panel.refreshTargetRegistry();
+
+ second.resolve({ targets: [pickerTarget('newest-target')] });
+ await flushPickerPromises();
+ first.resolve({ targets: [pickerTarget('requested-target')] });
+ await flushPickerPromises();
+
+ expect(harness.panel.state.registryTargets.map(target => target.id)).toEqual(['newest-target']);
+ expect(harness.panel.state.target).toBeNull();
+ expect(harness.panel.state.unavailableTargetId).toBe('requested-target');
+ });
+});
+
+describe('integrated DevTools console panel', () => {
+ let eventSources: MockConsoleEventSource[];
+ let panel: DevToolsConsolePanel;
+ let requests: Array<{ body?: string; headers?: Record; url: string }>;
+
+ beforeEach(() => {
+ eventSources = [];
+ requests = [];
+ const rawSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'devtools-panel.js'), 'utf8');
+ const source = rawSource.replace('void connectToInspectedApplication();', 'void 0;');
+ const elements = new Map