diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md index d20b115f..7e16367b 100644 --- a/npm_modules/cli/debugger/README.md +++ b/npm_modules/cli/debugger/README.md @@ -46,10 +46,11 @@ Important routes: - `/api/devtools/targets`: returns a fresh, bounded registry of native Valdi, explicit web-preview, and JavaScript-proxy targets. - `/api/devtools/target`: resolves either one opaque native target ID or the exact configured inspected Chromium page identity. - `/api/devtools/snapshot`, `/api/devtools/highlight`, and `/api/devtools/evaluate`: proxy the explicit web debugger bridge contract through loopback CDP. +- `/api/devtools/component-property`: applies one exact web-only scalar ViewModel property edit from a strict body-only identity and token tuple. - `/api/devtools/performance/snapshot` and `/api/devtools/performance/trace/*`: sample the exact web preview and record one bounded global Chromium trace without changing the daemon/Hermes `/api/performance/*` routes. -Web preview targets advertise the `component-properties` capability. Their -Elements snapshots may include a read-only `Valdi props` projection captured +Web preview targets advertise the independent `component-properties` +capability. Their Elements snapshots may include a `Valdi props` projection captured from own enumerable data descriptors only; accessors, inherited fields, and symbol keys are omitted. Component properties share a 64 KiB UTF-8 budget and are discarded before the hierarchy if the complete snapshot reaches its @@ -58,8 +59,51 @@ reflecting a Proxy can execute its `ownKeys` and descriptor traps and may materialize their complete key result before the cap is applied. A throwing or revoked Proxy therefore causes that component's properties to be omitted while preserving the hierarchy. Prototype traversal is not used, and property values -are never read through normal property access. Native targets do not advertise -or emit this data. +are never read through normal property access. + +An accepted web snapshot may additionally promote the separate +`component-property-edit` capability when both the renderer's dedicated +full-ViewModel mutation API and secure browser randomness are available. Static +target and registry responses do not claim this capability before the bridge +proves it. Editable strings, booleans, and finite numbers receive a 128-bit +lowercase hexadecimal token and a positive snapshot revision. Tokens bind the +exact component, virtual node, raw ViewModel identity, property name, data +descriptor, scalar type, and prior value. Tokens are single-use and expire +after 120 seconds. Each published snapshot replaces the current registry while +retaining only the immediately previous published revision, capped at 1,000 +tokens per revision and 2,000 total, so a panel may safely discard one +already-requested poll without invalidating the snapshot it still displays. +Publishing another revision drops the older retained map; destruction, +secure-randomness failure, and expiry clear the associated object graphs. +Missing crypto, reflection failures, stale identity, invalid values, and +mutation failures fall back to the read-only property presentation with a +generic error. `children`, `prototype`, +`constructor`, and `__proto__` are never editable. ViewModels with a custom +prototype, more than 1,000 own keys, any accessor descriptor, or an own +function-valued data property remain read only. Plain and null-prototype +ViewModels may edit frozen scalar descriptors without mutating the source. + +The renderer installs a read-only overlay Proxy over a stable shadow target +rather than using or mutating the exact prior ViewModel as the Proxy target. The +shadow snapshots the original prototype, complete descriptor set, own-key order, and +extensibility. Non-edited reads retain the exact source ViewModel as their +receiver, while repeated debugger edits flatten onto the same source without +depending on its later structural state. User Proxy `get` and `has` behavior +remains observable during fallback reads. If a Proxy `get` trap returns a +callable despite its data descriptor, the overlay returns a stable per-key +binding to the exact source ViewModel so method calls preserve receiver-private +state without invoking the trap during authorization. Inherited +`Object.prototype` members instead resolve against the shadow with the overlay +receiver, preserving their identity while routing legacy property mutators +through the overlay's rejection traps. Custom `ownKeys` behavior is captured +transactionally and served from the stable shadow after construction rather +than consulting the live source again. +Debugger writes, definitions, deletions, prototype changes, and +`preventExtensions` calls are rejected, while the dedicated full-ViewModel +rerender path receives the overlay. Native targets never advertise +`component-property-edit`, and the route cannot be reached through `targetId`, +the daemon protocol, the generic action bus, console evaluation, storage, or +telemetry. Renderer tracing uses the runtime debugger protocol and the existing native trace recorder. Captures are process-wide: the selected context is the capture @@ -73,9 +117,20 @@ Hermes CPU profiling uses the existing inspector transport. The native and synthetic previews forward capability, query, tap, focus, text, key, and scroll requests through the selected target's bounded input contract. Web-renderer inspection uses the first-party bridge exposed as -`window.__VALDI_WEB_DEBUGGER__` with `getSnapshot()`, `highlightNode()`, and -`clearHighlight()`. The DevTools panel proxies inspection through the exact -configured loopback Chromium target. +`window.__VALDI_WEB_DEBUGGER__` with `getSnapshot()`, `highlightNode()`, +`clearHighlight()`, and the synchronous exact `editComponentProperty()` path. +The DevTools panel proxies inspection through the exact configured loopback +Chromium target. Scalar controls remain read only unless the current snapshot +advertises both property capabilities and supplies valid edit metadata. While +an editor is focused or an edit owns its replacement snapshot refresh, +automatic refresh is paused; target, snapshot, selection, and operation +generations prevent stale completions from changing newer presentation. +Editable controls are hydrated with DOM APIs. Authorization tokens, component +identity, revisions, and the actionable binding stay out of serialized markup +in a private binding; the display property name is assigned only as safe DOM +text and an accessible name. String controls use a strict JSON string literal so +carriage returns, line feeds, NULs, quotes, unpaired surrogates, and unusual +nonblank property names round-trip exactly. Web-preview performance requests require the exact `sessionId`, `inspectedUrl`, and per-tab `targetNonce`; incomplete, stale, or cross-tab identities fail diff --git a/npm_modules/cli/debugger/devtools-panel.css b/npm_modules/cli/debugger/devtools-panel.css index 86c41eb9..06267691 100644 --- a/npm_modules/cli/debugger/devtools-panel.css +++ b/npm_modules/cli/debugger/devtools-panel.css @@ -476,6 +476,54 @@ button { 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); diff --git a/npm_modules/cli/debugger/devtools-panel.js b/npm_modules/cli/debugger/devtools-panel.js index e6558e68..cef25371 100644 --- a/npm_modules/cli/debugger/devtools-panel.js +++ b/npm_modules/cli/debugger/devtools-panel.js @@ -10,7 +10,19 @@ const MAX_CONSOLE_HISTORY_ENTRIES = 100; const MAX_PERFORMANCE_SAMPLES = 120; const MAX_PERFORMANCE_TIMELINE_ROWS = 120; const MAX_PERFORMANCE_SUMMARY_ROWS = 12; -const MANUAL_WEB_CAPABILITIES = new Set(['components', 'console', 'highlight', 'performance', 'snapshot', 'storage']); +const COMPONENT_PROPERTY_TOKEN_PATTERN = /^[0-9a-f]{32}$/; +const COMPONENT_PROPERTY_EDIT_ERROR = 'The component property edit is stale or invalid.'; +const FORBIDDEN_COMPONENT_PROPERTY_NAMES = new Set(['__proto__', 'children', 'constructor', 'prototype']); +const componentPropertyEditorBindings = new WeakMap(); +const MANUAL_WEB_CAPABILITIES = new Set([ + 'component-properties', + 'components', + 'console', + 'highlight', + 'performance', + 'snapshot', + 'storage', +]); function parseLaunchIdentity(searchParams) { const targetIds = searchParams.getAll('targetId'); @@ -73,6 +85,7 @@ const state = { autoRefresh: true, refreshTimer: null, refreshPending: false, + snapshotRequestCompletion: null, snapshotGeneration: 0, snapshotRequestGeneration: 0, hoveredNodeId: null, @@ -104,6 +117,12 @@ const state = { traceScope: 'valdi', traceSearch: '', }, + componentPropertyEdit: { + error: null, + focused: false, + operationGeneration: 0, + pending: false, + }, error: null, }; @@ -542,9 +561,17 @@ function resetConsoleForTargetChange() { elements.consoleInput.value = ''; } +function resetComponentPropertyEditForTargetChange() { + state.componentPropertyEdit.operationGeneration++; + state.componentPropertyEdit.pending = false; + state.componentPropertyEdit.focused = false; + state.componentPropertyEdit.error = null; +} + function clearTargetPresentation(message) { state.snapshotRequestGeneration++; state.refreshPending = false; + state.snapshotRequestCompletion = null; state.snapshot = null; state.snapshotGeneration++; state.selectedNodeId = null; @@ -552,6 +579,7 @@ function clearTargetPresentation(message) { state.expandedNodeIds.clear(); resetHighlightForTargetChange(); resetConsoleForTargetChange(); + resetComponentPropertyEditForTargetChange(); preparePerformanceForTargetChange(); elements.treeEmpty.textContent = message; render(); @@ -813,9 +841,11 @@ async function connectToInspectedPage() { stopConsoleStream(); enqueueExactHighlightClear(state.target); resetConsoleForTargetChange(); + resetComponentPropertyEditForTargetChange(); preparePerformanceForTargetChange(); state.snapshotRequestGeneration++; state.refreshPending = false; + state.snapshotRequestCompletion = null; state.snapshot = null; state.snapshotGeneration++; state.selectedNodeId = null; @@ -865,8 +895,33 @@ async function connectToInspectedApplication() { } async function refreshSnapshot() { - if (!state.target || !targetSupports('components') || !targetSupports('snapshot') || state.refreshPending) return; + await refreshSnapshotInternal(null); +} + +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()); + if (!refreshIsAllowed()) return; + if (state.refreshPending) { + const activeRequestCompletion = state.snapshotRequestCompletion; + if (!componentPropertyEditOwnsRefresh() || activeRequestCompletion === null) return; + await activeRequestCompletion; + if (!refreshIsAllowed() || state.refreshPending) return; + } 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; @@ -876,11 +931,26 @@ async function refreshSnapshot() { state.snapshotRequestGeneration === requestGeneration; try { const snapshot = await requestJson('/api/devtools/snapshot', targetIdentityParameters(requestTarget), {}); - if (!requestIsCurrent()) return; + if ( + !requestIsCurrent() || + ((state.componentPropertyEdit.focused || state.componentPropertyEdit.pending) && + !componentPropertyEditOwnsRefresh()) + ) + return; + 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 shouldClearHighlight = state.hoveredNodeId !== null || state.highlightMayBeActive; state.snapshot = snapshot; + state.componentPropertyEdit.error = null; state.snapshotGeneration++; if (state.highlightTimer) window.clearTimeout(state.highlightTimer); state.highlightTimer = null; @@ -909,7 +979,11 @@ async function refreshSnapshot() { } catch (error) { if (requestIsCurrent()) reportError(error); } finally { - if (requestIsCurrent()) state.refreshPending = false; + if (state.snapshotRequestCompletion === requestCompletion) { + state.snapshotRequestCompletion = null; + state.refreshPending = false; + } + resolveRequestCompletion(); } } @@ -919,6 +993,7 @@ function startRefreshTimer() { if (document.hidden) return; if (isDirectMode()) void refreshTargetRegistry(); if (!state.autoRefresh) return; + if (state.componentPropertyEdit.focused || state.componentPropertyEdit.pending) return; if (state.activeSection === 'elements') void refreshSnapshot(); if (state.activeSection === 'performance') void refreshPerformance({ silent: true }); }, 1200); @@ -1025,6 +1100,241 @@ function propertyRows(attributes, options) { .join(''); } +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; + try { + const result = await requestJson( + '/api/devtools/component-property', + {}, + { + body: { + ...targetIdentityParameters(requestTarget), + componentId, + componentToken, + propertyName, + snapshotRevision, + value, + }, + }, + ); + if (requestIsCurrent()) { + updated = result.updated === true; + if (updated) { + const previousSnapshotGeneration = state.snapshotGeneration; + await refreshSnapshotInternal(operationGeneration); + refreshed = state.snapshotGeneration !== previousSnapshotGeneration; + } + } + } 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 && state.componentPropertyEdit.error === null) { + state.componentPropertyEdit.error = COMPONENT_PROPERTY_EDIT_ERROR; + } + 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 { @@ -1034,12 +1344,14 @@ function componentMetadata(node) { }; } -function renderComponentProperties(node) { +function renderComponentProperties(node, editorModels) { if (node.component?.properties === undefined || !targetSupports('component-properties')) return ''; + const editable = targetSupports('component-property-edit') && state.componentPropertyEdit.error === null; return `
-
Valdi props read only
-
${propertyRows(node.component.properties, { css: false })}
+
Valdi props ${editable ? 'editable scalars' : 'read only'}
+ ${state.componentPropertyEdit.error ? `` : ''} +
${componentPropertyRows(node, editorModels)}
`; } @@ -1124,16 +1436,23 @@ function renderInspector() { } const renderedNode = inspectedNode(node); - const componentProperties = renderComponentProperties(node); + const componentPropertyEditorModels = []; + const componentProperties = renderComponentProperties(node, componentPropertyEditorModels); + const renderMarkup = markup => { + elements.inspector.innerHTML = markup; + hydrateComponentPropertyEditors(componentPropertyEditorModels); + }; if (node.component && renderedNode === node) { - elements.inspector.innerHTML = `
Valdi component ${escapeHtml(node.tag)}
${propertyRows(componentMetadata(node), { css: false })}${componentProperties}
This component does not currently render a backing element.
`; + renderMarkup( + `
Valdi component ${escapeHtml(node.tag)}
${propertyRows(componentMetadata(node), { css: false })}${componentProperties}
This component does not currently render a backing element.
`, + ); return; } if (state.activeDetail === 'styles') { - elements.inspector.innerHTML = `${componentProperties}${renderStyles(renderedNode)}`; + renderMarkup(`${componentProperties}${renderStyles(renderedNode)}`); } else if (state.activeDetail === 'computed') { - elements.inspector.innerHTML = `${componentProperties}${renderComputed(renderedNode)}`; + renderMarkup(`${componentProperties}${renderComputed(renderedNode)}`); } else { const textContent = renderedNode.element?.dom?.textContent ? valdiDebuggerTreeModel.formatValue(renderedNode.element.dom.textContent, 0) @@ -1141,7 +1460,9 @@ function renderInspector() { const componentDetails = node.component ? `
Valdi component ${escapeHtml(node.tag)}
${propertyRows(componentMetadata(node), { css: false })}` : ''; - elements.inspector.innerHTML = `${componentDetails}${componentProperties}
Rendered <${escapeHtml(valdiDebuggerTreeModel.formatValue(renderedNode.element?.dom?.tagName || 'div', 0))}>
${propertyRows(renderedNode.element?.dom?.attributes, { css: false })}${textContent ? `
Text content
${escapeHtml(textContent)}
` : ''}`; + renderMarkup( + `${componentDetails}${componentProperties}
Rendered <${escapeHtml(valdiDebuggerTreeModel.formatValue(renderedNode.element?.dom?.tagName || 'div', 0))}>
${propertyRows(renderedNode.element?.dom?.attributes, { css: false })}${textContent ? `
Text content
${escapeHtml(textContent)}
` : ''}`, + ); } } @@ -2165,6 +2486,45 @@ function wireEvents() { 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.breadcrumbs.addEventListener('click', event => { const button = event.target.closest('[data-breadcrumb-id]'); if (button) selectNode(button.dataset.breadcrumbId); diff --git a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts index 50b08f7b..b551483a 100644 --- a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts +++ b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts @@ -6,7 +6,13 @@ import { Script } from 'node:vm'; interface DevToolsTreeNode { bounds?: { height: number; width: number; x: number; y: number }; children: DevToolsTreeNode[]; - component?: { elementId?: string; key: string; name: string; properties?: Record }; + component?: { + elementId?: string; + key: string; + name: string; + properties?: Record; + propertyEdits?: Record; + }; element?: { attributes: Record; dom: { attributes: Record; tagName: string; textContent?: string }; @@ -34,10 +40,19 @@ 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; @@ -54,6 +69,14 @@ interface DevToolsHierarchyPanel { 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 { @@ -84,6 +107,7 @@ interface PickerTarget { name: string; platform: string; port?: number; + sessionId?: string; state: 'attached' | 'available' | 'waiting'; transport: string; } @@ -120,16 +144,16 @@ interface PickerStubElement { value: string; addEventListener(type: string, listener: (event: PickerStubEvent) => void): void; append(child: PickerStubElement): void; - closest(): PickerStubElement | null; + closest(selector: string): PickerStubElement | null; contains(): 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(): PickerStubElement | null; - querySelectorAll(): PickerStubElement[]; + querySelector(selector: string): PickerStubElement | null; + querySelectorAll(selector: string): PickerStubElement[]; removeAttribute(name: string): void; - replaceChildren(): void; + replaceChildren(...children: PickerStubElement[]): void; scrollIntoView(): void; setAttribute(name: string, value: string): void; setSelectionRange(): void; @@ -142,6 +166,12 @@ interface PickerPanel { consoleEntryKeys: Set; consoleHistory: string[]; error: string | null; + componentPropertyEdit: { + error: string | null; + focused: boolean; + operationGeneration: number; + pending: boolean; + }; expandedNodeIds: Set; highlightMayBeActive: boolean; highlightRequestTail: Promise; @@ -162,6 +192,7 @@ interface PickerPanel { registryTargets: PickerTarget[]; selectedNodeId: string | null; snapshot: { tree: DevToolsTreeNode } | null; + snapshotGeneration: number; target: PickerTarget | null; targetGeneration: number; targetSwitchMessage: string | null; @@ -169,6 +200,12 @@ interface PickerPanel { }; 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; @@ -178,6 +215,14 @@ interface PickerPanel { 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 { @@ -252,6 +297,19 @@ function componentTree(): DevToolsTreeNode { }; } +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, @@ -321,13 +379,28 @@ function createPickerStubElement(id: string): PickerStubElement { focus() {}, getAttribute: name => attributes.get(name) ?? null, getBoundingClientRect: () => ({ bottom: 500, height: 500, left: 0, right: 500, top: 0, width: 500 }), - querySelector: () => null, - querySelectorAll: () => [], + 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() { - element.children = []; + replaceChildren(...children) { + element.children = children; element.value = ''; }, scrollIntoView() {}, @@ -365,6 +438,7 @@ interface PickerHarness { 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 { @@ -486,7 +560,11 @@ function createPickerHarness(search: string): PickerHarness { location: { origin: 'http://127.0.0.1:18768', search }, parent: {}, removeEventListener() {}, - setInterval: () => nextTimerId++, + setInterval(callback: () => void): number { + const timerId = nextTimerId++; + timers.set(timerId, callback); + return timerId; + }, setTimeout(callback: () => void): number { const timerId = nextTimerId++; timers.set(timerId, callback); @@ -495,7 +573,7 @@ function createPickerHarness(search: string): PickerHarness { }; const panel = new Script( - `${treeModelSource}\n${panelSource}\n({ applyDirectTargetSelection, connectToInspectedApplication, evaluateConsoleExpression, parseTargetRegistry, queueHighlight, refreshSnapshot, refreshTargetRegistry, renderTargetPicker, runPerformanceAction, setActiveSection, startConsoleStream, state })`, + `${treeModelSource}\n${panelSource}\n({ applyDirectTargetSelection, connectToInspectedApplication, createComponentPropertyEditor, evaluateConsoleExpression, parseTargetRegistry, queueHighlight, refreshSnapshot, refreshTargetRegistry, renderTargetPicker, runPerformanceAction, setActiveSection, startConsoleStream, startRefreshTimer, state, submitComponentPropertyEdit })`, ).runInNewContext({ Blob, EventSource: PickerEventSource, @@ -554,6 +632,9 @@ function createPickerHarness(search: string): PickerHarness { timers.delete(timerId); callback(); }, + setActiveElement(element) { + activeElement = element; + }, }; } @@ -613,7 +694,9 @@ describe('integrated DevTools component hierarchy', () => { }; const window = { addEventListener() {}, - clearInterval() {}, + clearInterval(timerId: number) { + timers.delete(timerId); + }, clearTimeout(timerId: number) { timers.delete(timerId); }, @@ -623,7 +706,11 @@ describe('integrated DevTools component hierarchy', () => { '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456', }, parent: {}, - setInterval: () => 1, + setInterval(callback: () => void): number { + const timerId = nextTimerId++; + timers.set(timerId, callback); + return timerId; + }, setTimeout(callback: () => void): number { const timerId = nextTimerId++; timers.set(timerId, callback); @@ -632,7 +719,7 @@ describe('integrated DevTools component hierarchy', () => { }; panel = new Script( - `${treeModelSource}\n${panelSource}\n({ clearTargetPresentation, findNode, inspectedNodeId, inspectorContent: elements.inspector, queueHighlight, refreshSnapshot, renderInspector, renderTree, selectNode, state, treeContent: elements.tree })`, + `${treeModelSource}\n${panelSource}\n({ clearTargetPresentation, findNode, inspectedNodeId, inspectorContent: elements.inspector, queueHighlight, refreshSnapshot, renderInspector, renderTree, selectNode, startRefreshTimer, state, submitComponentPropertyEdit, treeContent: elements.tree })`, ).runInNewContext({ URL, URLSearchParams, @@ -706,6 +793,318 @@ describe('integrated DevTools component hierarchy', () => { expect(panel.inspectorContent.innerHTML).not.toContain('aria-label="Valdi props"'); }); + it('renders accessible escaped scalar editors only with both capabilities and valid metadata', () => { + 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', + 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('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.'); @@ -916,6 +1315,222 @@ describe('integrated DevTools capability-aware target picker', () => { 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, + 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('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',