From 7a4b9710707477e29d9c6116135f8051b2d18de6 Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Wed, 26 Aug 2026 00:44:50 -0700 Subject: [PATCH] feat(debugger): add cross-platform input control --- docs/docs/command-line-references.md | 16 +- docs/docs/workflow-inspector.md | 61 +- .../cli/debugger/debugger-bootstrap.js | 21 +- npm_modules/cli/debugger/debugger-model.js | 138 +- .../cli/debugger/debugger-preview-html.js | 380 ++++- npm_modules/cli/debugger/debugger-state.js | 1 + npm_modules/cli/src/commands/inspect.ts | 7 +- .../commands/inspect_commands/input.spec.ts | 203 +++ .../src/commands/inspect_commands/input.ts | 225 +++ npm_modules/cli/src/core/packageFiles.spec.ts | 959 +++++++++++- .../cli/src/debugger/inputClient.spec.ts | 279 ++++ npm_modules/cli/src/debugger/inputClient.ts | 505 +++++++ npm_modules/cli/src/debugger/server.spec.ts | 110 ++ npm_modules/cli/src/debugger/server.ts | 114 +- .../cli/src/utils/daemonClient.spec.ts | 59 + npm_modules/cli/src/utils/daemonClient.ts | 12 + .../src/valdi/valdi_core/src/Valdi.ts | 13 + .../debugging/DebuggerInputMessageHandler.ts | 1232 +++++++++++++++ .../src/utils/RenderedElementUtils.ts | 80 +- .../test/DebuggerInputMessageHandler.spec.ts | 1328 +++++++++++++++++ .../macos/SCValdiMacOSAttributesBinder.h | 1 + .../macos/SCValdiMacOSAttributesBinder.mm | 132 ++ valdi/src/valdi/macos/SCValdiMacOSFunction.h | 7 +- valdi/src/valdi/macos/SCValdiMacOSFunction.mm | 15 + .../valdi/macos/SCValdiMacOSViewManager.mm | 104 +- valdi/src/valdi/macos/SCValdiObjCUtils.mm | 26 +- .../valdi/macos/Views/SCValdiMacOSTextField.m | 280 +++- .../macos/SCValdiMacOSViewManagerTests.mm | 384 +++++ 28 files changed, 6592 insertions(+), 100 deletions(-) create mode 100644 npm_modules/cli/src/commands/inspect_commands/input.spec.ts create mode 100644 npm_modules/cli/src/commands/inspect_commands/input.ts create mode 100644 npm_modules/cli/src/debugger/inputClient.spec.ts create mode 100644 npm_modules/cli/src/debugger/inputClient.ts create mode 100644 src/valdi_modules/src/valdi/valdi_core/src/debugging/DebuggerInputMessageHandler.ts create mode 100644 src/valdi_modules/src/valdi/valdi_test/test/DebuggerInputMessageHandler.spec.ts diff --git a/docs/docs/command-line-references.md b/docs/docs/command-line-references.md index 1d52d216d..0223e508a 100644 --- a/docs/docs/command-line-references.md +++ b/docs/docs/command-line-references.md @@ -155,7 +155,7 @@ Starts the Valdi [hotreloader](./start-about.md#prototype-quickly-with-hot-reloa `valdi debugger [--host host] [--port port] [--strict-port] [--json]`\ Starts a local browser-based Valdi debugger web interface. The debugger attaches to running Valdi daemon targets and exposes live view hierarchy, preview, -inspector data, element snapshots, heap dumps, and runtime logs. CPU profiling +inspector data, element snapshots, heap dumps, input dispatch, and runtime logs. CPU profiling uses a separate Hermes debugger connection. - The default host is `127.0.0.1`; the debugger rejects non-loopback bind @@ -166,6 +166,20 @@ uses a separate Hermes debugger connection. - Use `--json` to print one machine-readable startup object with the selected `url`, `port`, `requestedPort`, and `portWasAutoSelected` fields.

+`valdi inspect input [contextId]`\ +Queries or controls a running debug `valdi_application` through the default, +cross-platform debugger input contract. + +- Target an element with `--element-id`, `--accessibility-id`, or `--selector`. +- Use `--client` to choose a connected target and `--port 13591` for a + standalone macOS app; the default port `13592` targets in-app mobile clients. +- Action-specific values include `--text`, `--key`, `--focused`/`--no-focused`, + `--selection-start`, `--selection-end`, `--x`, `--y`, `--delta-x`, and + `--delta-y`. +- Each successful command writes exactly one JSON object to standard output. +- Start with `capabilities`, then use `query` to discover stable + `accessibilityId` selectors and available actions.

+ `valdi test [--module module_name] [--target target_name]`\ Executes the test(s) for the provided targets. Note that multiple modules OR targets can be provided to execute all tests simultaneously. If no modules or targets are provided, ALL tests within the current workspace will be ran.

diff --git a/docs/docs/workflow-inspector.md b/docs/docs/workflow-inspector.md index 2ee968acf..e2023b72d 100644 --- a/docs/docs/workflow-inspector.md +++ b/docs/docs/workflow-inspector.md @@ -42,6 +42,65 @@ Valdi Inspector is a desktop application, written in Valdi itself, which can be ``` You should now be able see and interact with the component from the provided component path in a window on your desktop +### Automating a live target + +Debug `valdi_application` targets register the debugger input contract automatically. The browser debugger uses +this contract for its interactive preview. The fastest scriptable path is the CLI, which prints exactly one JSON +result on standard output: + +```sh +valdi inspect input capabilities --port 13591 +valdi inspect input query --port 13591 --selector '#composer' +valdi inspect input text --port 13591 --accessibility-id composer --text 'Hello from automation' +valdi inspect input key --port 13591 --accessibility-id composer --key Enter +``` + +As with `valdi inspect tree` and `snapshot`, omit the context when only one is active, or pass it as the last +positional argument. The `capabilities` action is context-free and only needs a connected client. Use `--client` +when more than one target is connected. Port `13591` is the standalone macOS app port; the CLI's default `13592` +targets in-app mobile clients. + +The same contract is also exposed by the browser debugger for tools already using its HTTP API. Start +`valdi debugger --json`, then use the returned loopback URL: + +```sh +curl -X POST "$VALDI_DEBUGGER_URL/api/input?port=13591&clientId=CLIENT_ID&contextId=CONTEXT_ID" \ + -H 'content-type: application/json' \ + -d '{"type":"tap","accessibilityId":"send-button"}' +``` + +The response's `input.contractVersion` is `1`. Call `{"type":"capabilities"}` to discover the operations and +selector forms supported by the connected target. Contract version 1 provides: + +* `query` — returns typed element descriptors. With no selector, it returns all rendered elements in the + context. Descriptors include the element and parent IDs, tag, local and absolute frame, accessibility + metadata, enabled/focused state, and supported actions. +* `tap` — invokes the rendered element's nearest `onTap` callback. +* `focus` — sets the `focused` interactive attribute on a `textfield` or `textview`. +* `text` — sets the input value and selection, then invokes `onChange`. +* `key` — supports `Enter`/`Return`, `Escape`, grapheme-safe `Backspace`/`Delete`, and one printable grapheme. + Return inserts a newline in editable `textview` elements unless `ignoreNewlines` is set; return callbacks and + focus-closing behavior remain independent. +* `scroll` — changes the nearest scroll container's content offset by `deltaX` and `deltaY`. + +An action can identify its element with a numeric `elementId`, an `accessibilityId`, or one of these stable +selector forms: + +```json +{ "selector": "#composer" } +{ "selector": "[accessibilityId=\"composer\"]" } +{ "selector": { "accessibilityId": "composer", "tag": "textfield" } } +``` + +Prefer unique `accessibilityId` values. Ambiguous selectors fail and return the matching element descriptors +instead of choosing an arbitrary element. Numeric element IDs are scoped to one renderer context and may change +after a render or hot reload. + +Debugger input intentionally follows Valdi's rendered callbacks and interactive attributes rather than +synthesizing operating-system events. This makes the same contract work across platforms, including +SnapDrawing-backed elements. Use platform UI automation when validating behavior that specifically depends on +the operating system's event dispatch. + ## Brief implementation details [the implementation]: #todo-implementation-link @@ -68,5 +127,3 @@ The hot reloader establishes a TCP connection between the device/simulator and t * Inaccurate attribute inspection from CSS documents on .vue components * Of course, since the Component preview runs outside of iOS/android, any custom native view will not actually render anything - - diff --git a/npm_modules/cli/debugger/debugger-bootstrap.js b/npm_modules/cli/debugger/debugger-bootstrap.js index 90a7ae974..4d7abf002 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 => { @@ -83,7 +83,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 diff --git a/npm_modules/cli/debugger/debugger-model.js b/npm_modules/cli/debugger/debugger-model.js index 25f3d4955..a939d9a72 100644 --- a/npm_modules/cli/debugger/debugger-model.js +++ b/npm_modules/cli/debugger/debugger-model.js @@ -371,14 +371,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) { @@ -387,6 +389,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; } @@ -439,16 +544,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) { diff --git a/npm_modules/cli/debugger/debugger-preview-html.js b/npm_modules/cli/debugger/debugger-preview-html.js index 0bea06dc9..f83221546 100644 --- a/npm_modules/cli/debugger/debugger-preview-html.js +++ b/npm_modules/cli/debugger/debugger-preview-html.js @@ -1,4 +1,66 @@ // 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') .replace(/[^a-z0-9_-]+/gi, '-') @@ -147,7 +209,7 @@ function applyPreviewTextStyles(element, attrs) { } } -function createPreviewElement(node) { +function createPreviewElement(node, effectivelyDisabled) { const tag = String(node.tag || 'view').toLowerCase(); const attrs = getNodeAttributes(node); if (tag === 'textfield') { @@ -155,16 +217,16 @@ function createPreviewElement(node) { 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,12 +258,13 @@ 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 nodeId = getNodeId(node); @@ -212,6 +275,11 @@ function finishPreviewElement(element, node) { } element.dataset.previewNodeId = nodeId; if (elementId !== null) element.dataset.previewElementId = String(elementId); + 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 +302,28 @@ 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); + const effectivelyDisabled = isHtmlPreviewEffectivelyDisabled(node, ancestorDisabled); + const element = createPreviewElement(node, effectivelyDisabled); + finishPreviewElement(element, node, target, effectivelyDisabled); parent.appendChild(element); const childParent = canHostPreviewChildren(element) ? element : parent; - for (const child of node.children || []) appendPreviewNode(child, childParent); + for (const child of node.children || []) appendPreviewNode(child, childParent, target, effectivelyDisabled); return; } - for (const child of node.children || []) appendPreviewNode(child, parent); + for (const child of node.children || []) appendPreviewNode(child, parent, target, ancestorDisabled); } function canHostPreviewChildren(element) { @@ -273,6 +352,7 @@ function markHtmlPreviewSelection() { } function renderHtmlPreview() { + beginHtmlPreviewIncarnation(); elements.htmlPreviewRoot.replaceChildren(); elements.htmlPreviewRoot.classList.toggle('active', false); if (!hasSnapshotTree()) return false; @@ -285,7 +365,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 +377,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 selectHtmlPreviewNodeAtEvent(event) { - const { element, node } = previewElementNodeFromEvent(event); +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, + }, + ); +} + +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-state.js b/npm_modules/cli/debugger/debugger-state.js index 0d8bb024c..eccccfabb 100644 --- a/npm_modules/cli/debugger/debugger-state.js +++ b/npm_modules/cli/debugger/debugger-state.js @@ -51,6 +51,7 @@ const state = { lastDebuggerRevision: 0, rootSnapshotImage: null, rootSnapshotRequestId: 0, + inputRefreshTimers: new Map(), manualDetach: false, followLatestTarget: true, exportObjectUrl: null, 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..737d078d1 100644 --- a/npm_modules/cli/src/core/packageFiles.spec.ts +++ b/npm_modules/cli/src/core/packageFiles.spec.ts @@ -82,12 +82,13 @@ 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'"); }); it('does not let projected trees auto-load remote media', () => { @@ -106,6 +107,962 @@ describe('npm package contents', () => { expect(previewSource).not.toContain('frame.src = source'); }); + it('projects effective ancestor, accessibility, and touch-disabled state', () => { + const previewSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-preview-html.js'), 'utf8'); + const disabledStates = new vm.Script( + `${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 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( + `${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 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( + `${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 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( + `${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 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( + `${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 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( + `${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 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( + `${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'); diff --git a/npm_modules/cli/src/debugger/inputClient.spec.ts b/npm_modules/cli/src/debugger/inputClient.spec.ts new file mode 100644 index 000000000..347a27c49 --- /dev/null +++ b/npm_modules/cli/src/debugger/inputClient.spec.ts @@ -0,0 +1,279 @@ +import 'jasmine'; +import type { DaemonConnection } from '../utils/daemonClient'; +import { + DEBUGGER_INPUT_IDENTIFIER, + DebuggerInputType, + sendDebuggerInput, + unwrapDebuggerInputResponse, +} from './inputClient'; + +function makeHandledResponse(type: DebuggerInputType, result: Record): Record { + return { + handled: true, + data: { + contractVersion: 1, + handled: true, + type, + ...result, + }, + }; +} + +describe('debugger input client', () => { + it('sends the shared request identifier and unwraps the target result', async () => { + const targetResult = { + contractVersion: 1, + handled: true, + type: DebuggerInputType.Query, + action: DebuggerInputType.Query, + elements: [], + }; + const customRequest = jasmine.createSpy('customRequest').and.resolveTo({ handled: true, data: targetResult }); + const conn = { customRequest } as unknown as DaemonConnection; + const request = { + type: DebuggerInputType.Query, + contextId: 'context-1', + selector: '#composer', + }; + + const result = await sendDebuggerInput(conn, 'client-1', request); + + expect(customRequest).toHaveBeenCalledOnceWith('client-1', DEBUGGER_INPUT_IDENTIFIER, request, 5000); + expect(result).toEqual(targetResult); + }); + + it('returns a structured unsupported-target result', () => { + expect( + unwrapDebuggerInputResponse( + { handled: false }, + { + type: DebuggerInputType.Tap, + elementId: 3, + }, + ), + ).toEqual({ + handled: false, + type: DebuggerInputType.Tap, + elementId: 3, + message: 'The target app did not register the Valdi debugger input handler.', + }); + }); + + it('accepts context-free capabilities and rejects malformed target responses', async () => { + const customRequest = jasmine.createSpy('customRequest').and.resolveTo({ + handled: true, + data: { + contractVersion: 1, + handled: true, + type: DebuggerInputType.Capabilities, + action: DebuggerInputType.Capabilities, + supportedTypes: Object.values(DebuggerInputType), + selectorForms: ['elementId'], + }, + }); + const conn = { customRequest } as unknown as DaemonConnection; + + await expectAsync(sendDebuggerInput(conn, 'client-1', { type: DebuggerInputType.Capabilities })).toBeResolvedTo( + jasmine.objectContaining({ handled: true, type: DebuggerInputType.Capabilities }), + ); + expect(customRequest).toHaveBeenCalledOnceWith( + 'client-1', + DEBUGGER_INPUT_IDENTIFIER, + { type: DebuggerInputType.Capabilities }, + 5000, + ); + + for (const malformedResponse of [ + null, + {}, + { handled: 'yes' }, + { handled: true }, + { handled: true, data: [] }, + { handled: true, data: { contractVersion: 1, type: 'query' } }, + { handled: true, data: { contractVersion: 0, handled: true, type: 'query' } }, + { handled: true, data: { contractVersion: 1, handled: true, type: 'tap' } }, + { handled: true, data: { contractVersion: 1, handled: true, type: 'query' } }, + { handled: true, data: { contractVersion: 1, handled: true, type: 'query', elements: 'bad' } }, + { + handled: true, + data: { + contractVersion: 1, + handled: true, + type: 'query', + elements: [{ elementId: 1, tag: 'view' }], + }, + }, + { + handled: true, + data: { + contractVersion: 1, + handled: true, + type: 'tap', + elementId: 1.5, + action: 'onTap', + actionElementId: 1, + }, + }, + { + handled: true, + data: { + contractVersion: 1, + handled: true, + type: 'text', + elementId: 1, + action: 'onChange', + actionElementId: 1, + value: 'safe', + selectionStart: Number.NaN, + selectionEnd: 4, + }, + }, + ]) { + expect(() => unwrapDebuggerInputResponse(malformedResponse, { type: 'query' })).toThrowError( + /Invalid debugger input response/, + ); + } + }); + + it('rejects invalid Unicode in raw selector strings before daemon dispatch', async () => { + const customRequest = jasmine.createSpy('customRequest'); + const conn = { customRequest } as unknown as DaemonConnection; + + await expectAsync( + sendDebuggerInput(conn, 'client-1', { + type: DebuggerInputType.Query, + selector: String.fromCodePoint(0xd8_3d), + }), + ).toBeRejectedWithError('selector must contain valid Unicode.'); + expect(customRequest).not.toHaveBeenCalled(); + }); + + it('validates action-specific handled response actions and fields', () => { + const validCases: Array<{ + request: Record; + response: Record; + }> = [ + { + request: { type: DebuggerInputType.Tap, elementId: 1 }, + response: makeHandledResponse(DebuggerInputType.Tap, { + elementId: 1, + action: 'onTap', + actionElementId: 2, + }), + }, + { + request: { type: DebuggerInputType.Focus, elementId: 1, focused: true }, + response: makeHandledResponse(DebuggerInputType.Focus, { + elementId: 1, + action: 'focused', + actionElementId: 1, + }), + }, + { + request: { type: DebuggerInputType.Text, elementId: 1, text: 'draft' }, + response: makeHandledResponse(DebuggerInputType.Text, { + elementId: 1, + action: 'onChange', + actionElementId: 1, + value: 'draft', + selectionStart: 5, + selectionEnd: 5, + }), + }, + { + request: { type: DebuggerInputType.Key, elementId: 1, key: 'Enter' }, + response: makeHandledResponse(DebuggerInputType.Key, { + elementId: 1, + action: 'onReturn', + actionElementId: 1, + value: 'draft', + selectionStart: 5, + selectionEnd: 5, + }), + }, + { + request: { type: DebuggerInputType.Key, elementId: 1, key: 'Escape' }, + response: makeHandledResponse(DebuggerInputType.Key, { + elementId: 1, + action: 'focused', + actionElementId: 1, + value: 'draft', + }), + }, + { + request: { type: DebuggerInputType.Scroll, elementId: 1, deltaY: 3 }, + response: makeHandledResponse(DebuggerInputType.Scroll, { + elementId: 1, + action: 'contentOffset', + actionElementId: 4, + contentOffsetX: 0, + contentOffsetY: 3, + }), + }, + ]; + for (const testCase of validCases) { + expect(unwrapDebuggerInputResponse(testCase.response, testCase.request)['handled']).toBeTrue(); + } + + const invalidCases: Array<{ + request: Record; + response: Record; + }> = [ + { + request: { type: DebuggerInputType.Tap, elementId: 1 }, + response: makeHandledResponse(DebuggerInputType.Tap, { + elementId: 1, + action: 'focused', + actionElementId: 1, + }), + }, + { + request: { type: DebuggerInputType.Focus, elementId: 1 }, + response: makeHandledResponse(DebuggerInputType.Focus, { elementId: 1, action: 'focused' }), + }, + { + request: { type: DebuggerInputType.Text, elementId: 1, text: 'draft' }, + response: makeHandledResponse(DebuggerInputType.Text, { + elementId: 1, + action: 'onChange', + actionElementId: 1, + value: 'draft', + selectionStart: 5, + }), + }, + { + request: { type: DebuggerInputType.Key, elementId: 1, key: 'Backspace' }, + response: makeHandledResponse(DebuggerInputType.Key, { + elementId: 1, + action: 'focused', + actionElementId: 1, + value: 'draf', + selectionStart: 4, + selectionEnd: 4, + }), + }, + { + request: { type: DebuggerInputType.Key, elementId: 1, key: 'Escape' }, + response: makeHandledResponse(DebuggerInputType.Key, { + elementId: 1, + action: 'focused', + actionElementId: 1, + }), + }, + { + request: { type: DebuggerInputType.Scroll, elementId: 1, deltaY: 3 }, + response: makeHandledResponse(DebuggerInputType.Scroll, { + elementId: 1, + action: 'contentOffset', + actionElementId: 4, + contentOffsetX: 0, + }), + }, + ]; + for (const testCase of invalidCases) { + expect(() => unwrapDebuggerInputResponse(testCase.response, testCase.request)).toThrowError( + /Invalid debugger input response/, + ); + } + }); +}); diff --git a/npm_modules/cli/src/debugger/inputClient.ts b/npm_modules/cli/src/debugger/inputClient.ts new file mode 100644 index 000000000..5d212e02b --- /dev/null +++ b/npm_modules/cli/src/debugger/inputClient.ts @@ -0,0 +1,505 @@ +import type { DaemonConnection } from '../utils/daemonClient'; + +export const DEBUGGER_INPUT_IDENTIFIER = 'ValdiDebuggerInput'; +const DEBUGGER_INPUT_TIMEOUT_MS = 5000; +const DEBUGGER_INPUT_NAMED_KEYS: ReadonlySet = new Set(['Enter', 'Return', 'Escape', 'Backspace', 'Delete']); + +interface SegmenterPart { + index: number; +} + +interface SegmenterLike { + segment(value: string): Iterable; +} + +interface SegmenterConstructor { + new (locales: undefined, options: { granularity: string }): SegmenterLike; +} + +export enum DebuggerInputType { + Capabilities = 'capabilities', + Query = 'query', + Tap = 'tap', + Focus = 'focus', + Text = 'text', + Key = 'key', + Scroll = 'scroll', +} + +const DEBUGGER_INPUT_RETURN_KEY_ACTIONS: ReadonlySet = new Set(['onReturn', 'onChange', 'focused', 'return']); +const DEBUGGER_INPUT_ESCAPE_KEY_ACTIONS: ReadonlySet = new Set(['focused']); +const DEBUGGER_INPUT_EDIT_KEY_ACTIONS: ReadonlySet = new Set(['onChange']); +const DEBUGGER_INPUT_TAP_ACTIONS: ReadonlySet = new Set(['onTap']); +const DEBUGGER_INPUT_FOCUS_ACTIONS: ReadonlySet = new Set(['focused']); +const DEBUGGER_INPUT_TEXT_ACTIONS: ReadonlySet = new Set(['onChange']); +const DEBUGGER_INPUT_SCROLL_ACTIONS: ReadonlySet = new Set(['contentOffset']); +export const SUPPORTED_DEBUGGER_INPUT_TYPES: ReadonlySet = new Set(Object.values(DebuggerInputType)); +const DEBUGGER_INPUT_SELECTOR_FIELDS: ReadonlySet = new Set(['elementId', 'accessibilityId', 'tag']); +const DEBUGGER_INPUT_COMMON_FIELDS: ReadonlyArray = [ + 'type', + 'contextId', + 'elementId', + 'accessibilityId', + 'selector', +]; +const DEBUGGER_INPUT_FIELDS_BY_TYPE: ReadonlyMap> = new Map([ + [DebuggerInputType.Capabilities, new Set(['type', 'contextId'])], + [DebuggerInputType.Query, new Set(DEBUGGER_INPUT_COMMON_FIELDS)], + [DebuggerInputType.Tap, new Set([...DEBUGGER_INPUT_COMMON_FIELDS, 'x', 'y'])], + [DebuggerInputType.Focus, new Set([...DEBUGGER_INPUT_COMMON_FIELDS, 'focused'])], + [ + DebuggerInputType.Text, + new Set([...DEBUGGER_INPUT_COMMON_FIELDS, 'text', 'value', 'selectionStart', 'selectionEnd']), + ], + [DebuggerInputType.Key, new Set([...DEBUGGER_INPUT_COMMON_FIELDS, 'key', 'selectionStart', 'selectionEnd'])], + [DebuggerInputType.Scroll, new Set([...DEBUGGER_INPUT_COMMON_FIELDS, 'x', 'y', 'deltaX', 'deltaY'])], +]); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function containsLoneSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.slice(index, index + 1).codePointAt(0) ?? 0; + if (codeUnit >= 0xd8_00 && codeUnit <= 0xdb_ff) { + if (index + 1 >= value.length) return true; + const nextCodeUnit = value.slice(index + 1, index + 2).codePointAt(0) ?? 0; + if (nextCodeUnit < 0xdc_00 || nextCodeUnit > 0xdf_ff) return true; + index += 1; + } else if (codeUnit >= 0xdc_00 && codeUnit <= 0xdf_ff) { + return true; + } + } + return false; +} + +function isCombiningCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0x03_00 && codePoint <= 0x03_6f) || + (codePoint >= 0x04_83 && codePoint <= 0x04_89) || + (codePoint >= 0x05_91 && codePoint <= 0x05_bd) || + codePoint === 0x05_bf || + (codePoint >= 0x05_c1 && codePoint <= 0x05_c2) || + (codePoint >= 0x06_10 && codePoint <= 0x06_1a) || + (codePoint >= 0x06_4b && codePoint <= 0x06_5f) || + codePoint === 0x06_70 || + (codePoint >= 0x06_d6 && codePoint <= 0x06_ed) || + (codePoint >= 0x1a_b0 && codePoint <= 0x1a_ff) || + (codePoint >= 0x1d_c0 && codePoint <= 0x1d_ff) || + (codePoint >= 0x20_d0 && codePoint <= 0x20_ff) || + (codePoint >= 0xfe_20 && codePoint <= 0xfe_2f) + ); +} + +function isGraphemeExtension(codePoint: number): boolean { + return ( + isCombiningCodePoint(codePoint) || + (codePoint >= 0xfe_00 && codePoint <= 0xfe_0f) || + (codePoint >= 0xe_01_00 && codePoint <= 0xe_01_ef) || + (codePoint >= 0x1_f3_fb && codePoint <= 0x1_f3_ff) || + codePoint === 0x20_e3 + ); +} + +function isRegionalIndicator(codePoint: number): boolean { + return codePoint >= 0x1_f1_e6 && codePoint <= 0x1_f1_ff; +} + +function fallbackGraphemeCount(value: string): number { + if (value.length === 0) return 0; + let count = 1; + let previousCodePoint = value.codePointAt(0) ?? 0; + let currentIndex = previousCodePoint > 0xff_ff ? 2 : 1; + let regionalIndicatorCount = isRegionalIndicator(previousCodePoint) ? 1 : 0; + while (currentIndex < value.length) { + const currentCodePoint = value.codePointAt(currentIndex) ?? 0; + const joinsPrevious = + (previousCodePoint === 0x00_0d && currentCodePoint === 0x00_0a) || + isGraphemeExtension(currentCodePoint) || + previousCodePoint === 0x20_0d || + currentCodePoint === 0x20_0d || + (isRegionalIndicator(previousCodePoint) && + isRegionalIndicator(currentCodePoint) && + regionalIndicatorCount % 2 === 1); + if (!joinsPrevious) { + count += 1; + if (count > 1) return count; + } + if (isRegionalIndicator(currentCodePoint)) { + regionalIndicatorCount = isRegionalIndicator(previousCodePoint) ? regionalIndicatorCount + 1 : 1; + } else if (!isGraphemeExtension(currentCodePoint)) { + regionalIndicatorCount = 0; + } + previousCodePoint = currentCodePoint; + currentIndex += currentCodePoint > 0xff_ff ? 2 : 1; + } + return count; +} + +function isSingleGrapheme(value: string): boolean { + const segmenterConstructor = + typeof Intl === 'undefined' ? undefined : (Intl as unknown as { Segmenter?: SegmenterConstructor }).Segmenter; + if (segmenterConstructor) { + try { + const segmenter = new segmenterConstructor(undefined, { granularity: 'grapheme' }); + const iterator = segmenter.segment(value)[Symbol.iterator](); + return !iterator.next().done && Boolean(iterator.next().done); + } catch { + // Fall through for Node runtimes without grapheme segmentation data. + } + } + return fallbackGraphemeCount(value) === 1; +} + +function isSupportedDebuggerInputKey(value: string): boolean { + if (DEBUGGER_INPUT_NAMED_KEYS.has(value)) return true; + if (value.length === 0 || containsLoneSurrogate(value) || !isSingleGrapheme(value)) return false; + let index = 0; + while (index < value.length) { + const codePoint = value.codePointAt(index) ?? 0; + if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) return false; + index += codePoint > 0xff_ff ? 2 : 1; + } + return true; +} + +function validateOptionalString( + request: Record, + fieldName: string, + requireNonEmpty: boolean, +): string | undefined { + const value = request[fieldName]; + if (value === undefined) return undefined; + if (typeof value !== 'string') return `${fieldName} must be a string.`; + if (requireNonEmpty && value.length === 0) return `${fieldName} must not be empty.`; + if (containsLoneSurrogate(value)) return `${fieldName} must contain valid Unicode.`; + return undefined; +} + +function validateOptionalNumber( + request: Record, + fieldName: string, + requireInteger: boolean, +): string | undefined { + const value = request[fieldName]; + if (value === undefined) return undefined; + if (typeof value !== 'number' || !Number.isFinite(value) || (requireInteger && !Number.isInteger(value))) { + return `${fieldName} must be a finite ${requireInteger ? 'integer' : 'number'}.`; + } + return undefined; +} + +function responseValidationError(message: string): never { + throw new TypeError(`Invalid debugger input response: ${message}`); +} + +function validateOptionalResponseString( + value: Record, + fieldName: string, + requireNonEmpty: boolean, +): void { + const fieldValue = value[fieldName]; + if (fieldValue === undefined) return; + if (typeof fieldValue !== 'string') responseValidationError(`${fieldName} must be a string.`); + if (requireNonEmpty && fieldValue.length === 0) responseValidationError(`${fieldName} must not be empty.`); + if (containsLoneSurrogate(fieldValue)) responseValidationError(`${fieldName} must contain valid Unicode.`); +} + +function validateOptionalResponseNumber( + value: Record, + fieldName: string, + requireInteger: boolean, +): void { + const fieldValue = value[fieldName]; + if (fieldValue === undefined) return; + if ( + typeof fieldValue !== 'number' || + !Number.isFinite(fieldValue) || + (requireInteger && !Number.isInteger(fieldValue)) + ) { + responseValidationError(`${fieldName} must be a finite ${requireInteger ? 'integer' : 'number'}.`); + } +} + +function requireResponseString(value: Record, fieldName: string): string { + validateOptionalResponseString(value, fieldName, false); + const fieldValue = value[fieldName]; + if (typeof fieldValue !== 'string') responseValidationError(`${fieldName} must be a string.`); + return fieldValue; +} + +function requireResponseNumber(value: Record, fieldName: string, requireInteger: boolean): number { + validateOptionalResponseNumber(value, fieldName, requireInteger); + const fieldValue = value[fieldName]; + if (typeof fieldValue !== 'number') { + responseValidationError(`${fieldName} must be a finite ${requireInteger ? 'integer' : 'number'}.`); + } + return fieldValue; +} + +function requireResponseAction(data: Record, allowedActions: ReadonlySet): void { + const action = data['action']; + if (typeof action !== 'string' || !allowedActions.has(action)) { + responseValidationError(`action must be one of: ${Array.from(allowedActions).join(', ')}.`); + } +} + +function validateStringArray(value: unknown, fieldName: string): void { + if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) { + responseValidationError(`${fieldName} must be an array of strings.`); + } +} + +function validateElementFrame(value: unknown, fieldName: string): void { + if (!isRecord(value)) responseValidationError(`${fieldName} must be an object.`); + for (const coordinate of ['x', 'y', 'width', 'height']) { + const coordinateValue = value[coordinate]; + if (typeof coordinateValue !== 'number' || !Number.isFinite(coordinateValue)) { + responseValidationError(`${fieldName}.${coordinate} must be a finite number.`); + } + } +} + +function validateElementDescriptor(value: unknown, index: number): void { + const fieldName = `elements[${index}]`; + if (!isRecord(value)) responseValidationError(`${fieldName} must be an object.`); + if (typeof value['elementId'] !== 'number' || !Number.isInteger(value['elementId'])) { + responseValidationError(`${fieldName}.elementId must be a finite integer.`); + } + validateOptionalResponseNumber(value, 'parentElementId', true); + if (typeof value['tag'] !== 'string' || value['tag'].length === 0) { + responseValidationError(`${fieldName}.tag must be a non-empty string.`); + } + for (const stringField of [ + 'accessibilityId', + 'accessibilityCategory', + 'accessibilityNavigation', + 'accessibilityLabel', + 'accessibilityHint', + 'accessibilityValue', + ]) { + validateOptionalResponseString(value, stringField, false); + } + for (const booleanField of ['selected', 'enabled', 'focused']) { + if (typeof value[booleanField] !== 'boolean') { + responseValidationError(`${fieldName}.${booleanField} must be a boolean.`); + } + } + validateElementFrame(value['frame'], `${fieldName}.frame`); + validateElementFrame(value['absoluteFrame'], `${fieldName}.absoluteFrame`); + validateStringArray(value['actions'], `${fieldName}.actions`); +} + +function validateDebuggerInputResult(data: Record, request: Record): void { + const requestType = request['type'] as DebuggerInputType; + validateOptionalResponseString(data, 'contextId', true); + validateOptionalResponseString(data, 'accessibilityId', false); + validateOptionalResponseString(data, 'action', true); + validateOptionalResponseString(data, 'message', false); + validateOptionalResponseString(data, 'value', false); + for (const fieldName of ['elementId', 'actionElementId', 'selectionStart', 'selectionEnd']) { + validateOptionalResponseNumber(data, fieldName, true); + } + for (const fieldName of ['contentOffsetX', 'contentOffsetY']) { + validateOptionalResponseNumber(data, fieldName, false); + } + + if (data['elements'] !== undefined) { + if (!Array.isArray(data['elements'])) responseValidationError('elements must be an array.'); + data['elements'].forEach((element, index) => validateElementDescriptor(element, index)); + } + if (data['supportedTypes'] !== undefined) validateStringArray(data['supportedTypes'], 'supportedTypes'); + if (data['selectorForms'] !== undefined) validateStringArray(data['selectorForms'], 'selectorForms'); + + if (!data['handled']) return; + if (requestType === DebuggerInputType.Query) { + if (data['action'] !== DebuggerInputType.Query) responseValidationError('query action must be query.'); + if (!Array.isArray(data['elements'])) responseValidationError('query elements must be an array.'); + } + if (requestType === DebuggerInputType.Capabilities) { + if (data['action'] !== DebuggerInputType.Capabilities) { + responseValidationError('capabilities action must be capabilities.'); + } + validateStringArray(data['supportedTypes'], 'supportedTypes'); + validateStringArray(data['selectorForms'], 'selectorForms'); + return; + } + if (requestType !== DebuggerInputType.Query) { + requireResponseNumber(data, 'elementId', true); + requireResponseNumber(data, 'actionElementId', true); + } + + switch (requestType) { + case DebuggerInputType.Tap: { + requireResponseAction(data, DEBUGGER_INPUT_TAP_ACTIONS); + break; + } + case DebuggerInputType.Focus: { + requireResponseAction(data, DEBUGGER_INPUT_FOCUS_ACTIONS); + break; + } + case DebuggerInputType.Text: { + requireResponseAction(data, DEBUGGER_INPUT_TEXT_ACTIONS); + requireResponseString(data, 'value'); + requireResponseNumber(data, 'selectionStart', true); + requireResponseNumber(data, 'selectionEnd', true); + break; + } + case DebuggerInputType.Key: { + const key = request['key']; + if (typeof key !== 'string') responseValidationError('key request must contain a string key.'); + const allowedActions = + key === 'Enter' || key === 'Return' + ? DEBUGGER_INPUT_RETURN_KEY_ACTIONS + : key === 'Escape' + ? DEBUGGER_INPUT_ESCAPE_KEY_ACTIONS + : DEBUGGER_INPUT_EDIT_KEY_ACTIONS; + requireResponseAction(data, allowedActions); + requireResponseString(data, 'value'); + if (key !== 'Escape') { + requireResponseNumber(data, 'selectionStart', true); + requireResponseNumber(data, 'selectionEnd', true); + } + break; + } + case DebuggerInputType.Scroll: { + requireResponseAction(data, DEBUGGER_INPUT_SCROLL_ACTIONS); + requireResponseNumber(data, 'contentOffsetX', false); + requireResponseNumber(data, 'contentOffsetY', false); + break; + } + default: { + break; + } + } +} + +function validateSelector(request: Record): string | undefined { + const selectorCount = [request['elementId'], request['accessibilityId'], request['selector']].filter( + value => value !== undefined, + ).length; + if (selectorCount > 1) return 'Use only one of elementId, accessibilityId, or selector.'; + + const elementIdError = validateOptionalNumber(request, 'elementId', true); + if (elementIdError) return elementIdError; + const accessibilityIdError = validateOptionalString(request, 'accessibilityId', true); + if (accessibilityIdError) return accessibilityIdError; + + const selector = request['selector']; + if (selector === undefined) return undefined; + if (typeof selector === 'string') { + return validateOptionalString(request, 'selector', true); + } + if (!isRecord(selector)) return 'selector must be a string or an object.'; + const unknownField = Object.keys(selector) + .sort() + .find(fieldName => !DEBUGGER_INPUT_SELECTOR_FIELDS.has(fieldName)); + if (unknownField) return `Unsupported selector field '${unknownField}'.`; + if (Object.keys(selector).length === 0) return 'selector object must include elementId, accessibilityId, or tag.'; + return ( + validateOptionalNumber(selector, 'elementId', true) ?? + validateOptionalString(selector, 'accessibilityId', true) ?? + validateOptionalString(selector, 'tag', true) + ); +} + +export function validateDebuggerInputRequest(request: unknown): string | undefined { + if (!isRecord(request)) return 'Debugger input request must be an object.'; + const type = request['type']; + if (typeof type !== 'string' || !SUPPORTED_DEBUGGER_INPUT_TYPES.has(type)) { + return `Unsupported input type ${String(type)}.`; + } + const inputType = type as DebuggerInputType; + const supportedFields = DEBUGGER_INPUT_FIELDS_BY_TYPE.get(inputType); + if (!supportedFields) return `Unsupported input type ${type}.`; + const unknownField = Object.keys(request) + .sort() + .find(fieldName => !supportedFields.has(fieldName)); + if (unknownField) return `Field '${unknownField}' is not supported for ${type} input.`; + + const contextError = validateOptionalString(request, 'contextId', true); + if (contextError) return contextError; + const selectorError = validateSelector(request); + if (selectorError) return selectorError; + const hasSelector = + request['elementId'] !== undefined || request['accessibilityId'] !== undefined || request['selector'] !== undefined; + if (inputType !== DebuggerInputType.Capabilities && inputType !== DebuggerInputType.Query && !hasSelector) { + return 'An elementId, accessibilityId, or selector is required.'; + } + + if (request['focused'] !== undefined && typeof request['focused'] !== 'boolean') { + return 'focused must be a boolean.'; + } + for (const fieldName of ['text', 'value', 'key']) { + const error = validateOptionalString(request, fieldName, fieldName === 'key'); + if (error) return error; + } + for (const fieldName of ['selectionStart', 'selectionEnd']) { + const error = validateOptionalNumber(request, fieldName, true); + if (error) return error; + } + for (const fieldName of ['x', 'y', 'deltaX', 'deltaY']) { + const error = validateOptionalNumber(request, fieldName, false); + if (error) return error; + } + + if (inputType === DebuggerInputType.Text) { + if (request['text'] === undefined && request['value'] === undefined) + return 'Text input requires a string text or value.'; + if (request['text'] !== undefined && request['value'] !== undefined) return 'Use only one of text or value.'; + } + if (inputType === DebuggerInputType.Key) { + if (request['key'] === undefined) return 'Key input requires a string key.'; + if (!isSupportedDebuggerInputKey(request['key'] as string)) { + return 'key must be Enter, Return, Escape, Backspace, Delete, or one printable grapheme.'; + } + } + return undefined; +} + +export function unwrapDebuggerInputResponse( + customBody: unknown, + request: Record, +): Record { + if (!isRecord(customBody) || typeof customBody['handled'] !== 'boolean') { + throw new TypeError('Invalid debugger input response: handled must be a boolean.'); + } + if (customBody['handled']) { + const data = customBody['data']; + if (!isRecord(data)) { + throw new TypeError('Invalid debugger input response: data must be an object.'); + } + if (typeof data['handled'] !== 'boolean') { + throw new TypeError('Invalid debugger input response: data.handled must be a boolean.'); + } + const contractVersion = data['contractVersion']; + if (typeof contractVersion !== 'number' || !Number.isInteger(contractVersion) || contractVersion < 1) { + throw new TypeError('Invalid debugger input response: data.contractVersion must be a positive integer.'); + } + if (typeof data['type'] !== 'string' || data['type'] !== request['type']) { + throw new TypeError('Invalid debugger input response: data.type must match the request type.'); + } + validateDebuggerInputResult(data, request); + return data; + } + return { + handled: false, + type: request['type'], + elementId: request['elementId'], + message: 'The target app did not register the Valdi debugger input handler.', + }; +} + +export async function sendDebuggerInput( + conn: DaemonConnection, + clientId: string, + request: Record, +): Promise> { + const validationError = validateDebuggerInputRequest(request); + if (validationError) { + throw new Error(validationError); + } + const customBody = await conn.customRequest(clientId, DEBUGGER_INPUT_IDENTIFIER, request, DEBUGGER_INPUT_TIMEOUT_MS); + return unwrapDebuggerInputResponse(customBody, request); +} diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts index 0582e76bb..3dbd13343 100644 --- a/npm_modules/cli/src/debugger/server.spec.ts +++ b/npm_modules/cli/src/debugger/server.spec.ts @@ -410,6 +410,116 @@ describe('debugger server', () => { expect((JSON.parse(invalidPort.body) as { error: string }).error).toContain('between 1 and 65535'); }); + it('rejects non-integer input element identifiers before connecting to a daemon', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const inputUrl = new URL('/api/input', debuggerServer.url).toString(); + const invalidBodies = [ + JSON.stringify({ type: 'tap', elementId: '12' }), + JSON.stringify({ type: 'tap', elementId: '12px' }), + JSON.stringify({ type: 'tap', elementId: 12.5 }), + JSON.stringify({ type: 'tap', elementId: null }), + '{"type":"tap","elementId":1e400}', + ]; + + for (const body of invalidBodies) { + const result = await request(inputUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + expect(result.statusCode).toBe(400); + expect((JSON.parse(result.body) as { error: string }).error).toBe('elementId must be a finite integer.'); + } + }); + + it('rejects action-specific input fields and malformed ports before connecting to a daemon', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const baseUrl = new URL('/api/input', debuggerServer.url); + const cases = [ + { + url: baseUrl.toString(), + body: { type: 'tap', elementId: 1, text: 'unexpected' }, + error: "Field 'text' is not supported for tap input.", + }, + { + url: baseUrl.toString(), + body: { type: 'text', elementId: 1, text: 'a', value: 'b' }, + error: 'Use only one of text or value.', + }, + { + url: baseUrl.toString(), + body: { type: 'key', elementId: 1, key: 'abc' }, + error: 'key must be Enter, Return, Escape, Backspace, Delete, or one printable grapheme.', + }, + { + url: new URL('/api/input?port=13591oops', debuggerServer.url).toString(), + body: { type: 'capabilities' }, + error: 'Input port must be an integer between 1 and 65535.', + }, + ]; + + for (const inputCase of cases) { + const result = await request(inputCase.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(inputCase.body), + }); + expect(result.statusCode).toBe(400); + expect((JSON.parse(result.body) as { error: string }).error).toBe(inputCase.error); + } + }); + + it('returns explicit client errors for unsupported input methods and malformed bodies', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const inputUrl = new URL('/api/input', debuggerServer.url).toString(); + + const unsupportedMethod = await request(inputUrl, GET_REQUEST_OPTIONS); + expect(unsupportedMethod.statusCode).toBe(405); + expect((JSON.parse(unsupportedMethod.body) as { error: string }).error).toBe('Input dispatch requires POST.'); + + const malformedJson = await request(inputUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{', + }); + expect(malformedJson.statusCode).toBe(400); + expect((JSON.parse(malformedJson.body) as { error: string }).error).toBe('Request body must contain valid JSON.'); + + for (const body of ['null', '[]', '"input"']) { + const malformedBody = await request(inputUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + expect(malformedBody.statusCode).toBe(400); + expect((JSON.parse(malformedBody.body) as { error: string }).error).toBe('Request body must be a JSON object.'); + } + + const oversizedBody = await request(inputUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ padding: 'a'.repeat(1024 * 1024) }), + }); + expect(oversizedBody.statusCode).toBe(400); + expect((JSON.parse(oversizedBody.body) as { error: string }).error).toBe('Request body is too large.'); + }); + it('decodes JSON only after joining split UTF-8 request bytes', async () => { debuggerServer = await startDebuggerServer({ assetRoot, diff --git a/npm_modules/cli/src/debugger/server.ts b/npm_modules/cli/src/debugger/server.ts index 1e948e229..5ab041361 100644 --- a/npm_modules/cli/src/debugger/server.ts +++ b/npm_modules/cli/src/debugger/server.ts @@ -15,6 +15,7 @@ import { } from '../utils/daemonClient'; import { getUserConfig, resolveFilePath } from '../utils/fileUtils'; import { type CpuProfile, HERMES_PORT, HermesConnection, listHermesDevices } from '../utils/hermesClient'; +import { DebuggerInputType, sendDebuggerInput, validateDebuggerInputRequest } from './inputClient'; const DEFAULT_HOST = process.env['VALDI_DEBUGGER_HOST'] || '127.0.0.1'; const DEFAULT_PORT = Number.parseInt(process.env['VALDI_DEBUGGER_PORT'] || '8765', 10); @@ -308,16 +309,21 @@ async function readJsonBody(request: IncomingMessage): Promise 1024 * 1024) { - throw new Error('Request body is too large.'); + throw new ApiRequestError(400, 'Request body is too large.'); } chunks.push(buffer); } const raw = Buffer.concat(chunks, byteLength).toString('utf8'); if (!raw.trim()) return {}; - const parsed = JSON.parse(raw) as unknown; + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + throw new ApiRequestError(400, 'Request body must contain valid JSON.'); + } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Request body must be a JSON object.'); + throw new ApiRequestError(400, 'Request body must be a JSON object.'); } return parsed as Record; } @@ -987,25 +993,7 @@ async function resolveTarget( contexts: RemoteContext[]; context: RemoteContext; }> { - const clients = await conn.listConnectedClients(); - if (clients.length === 0) { - throw new Error('No clients connected to this Valdi daemon.'); - } - - const requestedClientId = searchParams.get('clientId'); - const firstClient = clients[0]; - if (!firstClient) { - throw new Error('No clients connected to this Valdi daemon.'); - } - let client = firstClient; - if (requestedClientId !== null) { - const requestedClient = clients.find(candidate => candidate.client_id === requestedClientId); - if (!requestedClient) { - throw new Error(`No connected Valdi client has id ${requestedClientId}.`); - } - client = requestedClient; - } - + const { clients, client } = await resolveClient(searchParams, conn); const contexts = await conn.listContexts(client.client_id); if (contexts.length === 0) { throw new Error(`No Valdi contexts found for client ${client.client_id}.`); @@ -1028,6 +1016,32 @@ async function resolveTarget( return { clients, client, contexts, context }; } +async function resolveClient( + searchParams: URLSearchParams, + conn: DaemonConnection, +): Promise<{ clients: DaemonConnectedClient[]; client: DaemonConnectedClient }> { + const clients = await conn.listConnectedClients(); + if (clients.length === 0) { + throw new Error('No clients connected to this Valdi daemon.'); + } + + const requestedClientId = searchParams.get('clientId'); + const firstClient = clients[0]; + if (!firstClient) { + throw new Error('No clients connected to this Valdi daemon.'); + } + let client = firstClient; + if (requestedClientId !== null) { + const requestedClient = clients.find(candidate => candidate.client_id === requestedClientId); + if (!requestedClient) { + throw new Error(`No connected Valdi client has id ${requestedClientId}.`); + } + client = requestedClient; + } + + return { clients, client }; +} + function flattenTargets( port: number, clients: ClientWithContexts[], @@ -1133,6 +1147,57 @@ async function inspectHeap(request: IncomingMessage, searchParams: URLSearchPara }); } +async function dispatchInput( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise> { + if (request.method !== 'POST') { + throw new ApiRequestError(405, 'Input dispatch requires POST.'); + } + + const body = await readJsonBody(request); + const validationError = validateDebuggerInputRequest(body); + if (validationError) { + throw new ApiRequestError(400, validationError); + } + const inputType = body['type'] as DebuggerInputType; + + const portValue = searchParams.get('port'); + if (portValue !== null && !/^\d+$/.test(portValue)) { + throw new ApiRequestError(400, 'Input port must be an integer between 1 and 65535.'); + } + const port = portValue === null ? STANDALONE_PORT : Number(portValue); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new ApiRequestError(400, 'Input port must be an integer between 1 and 65535.'); + } + return await withConnection(port, async conn => { + const target = + inputType === DebuggerInputType.Capabilities + ? { ...(await resolveClient(searchParams, conn)), context: undefined } + : await resolveTarget(searchParams, conn); + const { client, context } = target; + + const elementLabel = body['elementId'] === undefined ? 'none' : String(body['elementId']); + console.log( + `[input] dispatch type=${inputType} element=${elementLabel} client=${client.client_id} context=${context?.id ?? 'none'}`, + ); + const result = await sendDebuggerInput(conn, client.client_id, { + ...body, + ...(context ? { contextId: context.id } : {}), + }); + console.log( + `[input] result handled=${String(Boolean(result['handled']))} element=${String(result['elementId'] ?? 'none')} action=${String(result['action'] ?? 'none')}`, + ); + + return { + port, + clientId: client.client_id, + contextId: context?.id, + input: result, + }; + }); +} + async function inspectProfileContexts(searchParams: URLSearchParams): Promise> { const port = readNumber(searchParams, 'hermesPort', HERMES_PORT); const contexts = await listHermesDevices(port); @@ -1359,6 +1424,11 @@ async function handleApi(request: IncomingMessage, response: ServerResponse, url return; } + if (url.pathname === '/api/input') { + sendJson(response, 200, await dispatchInput(request, url.searchParams)); + return; + } + if (url.pathname === '/api/performance/profile/status') { sendJson(response, 200, profileStatusPayload()); return; diff --git a/npm_modules/cli/src/utils/daemonClient.spec.ts b/npm_modules/cli/src/utils/daemonClient.spec.ts index 47d69742b..93b934d81 100644 --- a/npm_modules/cli/src/utils/daemonClient.spec.ts +++ b/npm_modules/cli/src/utils/daemonClient.spec.ts @@ -49,6 +49,43 @@ class ErrorResponseSocket extends EventEmitter { } } +// net.Socket uses EventEmitter semantics, which this protocol test mirrors. +// eslint-disable-next-line unicorn/prefer-event-target +class CustomResponseSocket extends EventEmitter { + readonly remotePort = 13_591; + request: Record | undefined; + + write(data: Buffer, callback?: (error?: Error) => void): boolean { + const packet = JSON.parse(data.subarray(8).toString('utf8')) as Record; + const event = packet['event'] as Record | undefined; + const payloadFromClient = event?.['payload_from_client'] as Record | undefined; + if (payloadFromClient) { + this.request = JSON.parse(String(payloadFromClient['payload_string'])) as Record; + const customResponse = { + request: { + forward_client_payload: { + client_id: 1, + payload_string: JSON.stringify({ + type: -1000, + requestId: this.request['requestId'], + body: { handled: true, data: { contractVersion: 1 } }, + }), + }, + request_id: 'device-response-1', + }, + }; + queueMicrotask(() => this.emit('data', encodeTestPacket(customResponse))); + } + callback?.(); + return true; + } + + destroy(): this { + this.emit('close'); + return this; + } +} + describe('DaemonConnection', () => { it('surfaces runtime error responses from debugger requests', async () => { const socket = new ErrorResponseSocket(); @@ -60,4 +97,26 @@ describe('DaemonConnection', () => { connection.close(); } }); + + it('sends custom debugger messages through the shared runtime request type', async () => { + const socket = new CustomResponseSocket(); + const connection = new DaemonConnection(socket as unknown as Socket); + + try { + const response = await connection.customRequest('1', 'ValdiDebuggerInput', { type: 'capabilities' }, 5000); + + expect(socket.request).toEqual( + jasmine.objectContaining({ + type: 1000, + body: { + identifier: 'ValdiDebuggerInput', + data: { type: 'capabilities' }, + }, + }), + ); + expect(response).toEqual({ handled: true, data: { contractVersion: 1 } }); + } finally { + connection.close(); + } + }); }); diff --git a/npm_modules/cli/src/utils/daemonClient.ts b/npm_modules/cli/src/utils/daemonClient.ts index c31c5636a..83fa04187 100644 --- a/npm_modules/cli/src/utils/daemonClient.ts +++ b/npm_modules/cli/src/utils/daemonClient.ts @@ -51,6 +51,8 @@ export const enum DaemonMsgType { TAKE_ELEMENT_SNAPSHOT_RESPONSE = -4, DUMP_HEAP_REQUEST = 5, DUMP_HEAP_RESPONSE = -5, + CUSTOM_REQUEST = 1000, + CUSTOM_RESPONSE = -1000, } // ─── Config ────────────────────────────────────────────────────────────────── @@ -133,6 +135,16 @@ export class DaemonConnection { socket.on('error', err => this.rejectAllPending(err)); } + async customRequest( + clientId: string, + identifier: string, + data: Record, + timeoutMs: number, + ): Promise> { + const resp = await this.forwardAndWait(clientId, DaemonMsgType.CUSTOM_REQUEST, { identifier, data }, timeoutMs); + return (resp['body'] ?? {}) as Record; + } + private rejectAllPending(err: Error): void { this.configureReady?.reject(err); this.configureReady = null; diff --git a/src/valdi_modules/src/valdi/valdi_core/src/Valdi.ts b/src/valdi_modules/src/valdi/valdi_core/src/Valdi.ts index 93d96dc7e..ee0d0c778 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/Valdi.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/Valdi.ts @@ -16,6 +16,7 @@ import { jsx } from './JSXBootstrap'; import { RootComponentsManager } from './RootComponentsManager'; import { Style } from './Style'; import { DaemonClientManager } from './debugging/DaemonClientManager'; +import { DebuggerInputMessageHandler } from './debugging/DebuggerInputMessageHandler'; import { mergePartial } from './utils/PartialUtils'; export interface Attributes { @@ -738,17 +739,29 @@ declare class Proxy { } let lastRootComponentsManager: RootComponentsManager | undefined; +let debuggerInputMessageHandler: DebuggerInputMessageHandler | undefined; export function getLastRootComponentsManager(): RootComponentsManager | undefined { return lastRootComponentsManager; } export function makeRootComponentsManager(): IRootComponentsManager { + if (debuggerInputMessageHandler) { + jsx.removeCustomMessageHandler(debuggerInputMessageHandler); + debuggerInputMessageHandler = undefined; + } lastRootComponentsManager = new RootComponentsManager( jsx, runtime.isDebugEnabled ? jsx.daemonClientManager : undefined, runtime.submitDebugMessage, ); + if (runtime.isDebugEnabled) { + const rootComponentsManager = lastRootComponentsManager; + debuggerInputMessageHandler = new DebuggerInputMessageHandler( + contextId => rootComponentsManager.rootComponents[contextId]?.renderer, + ); + jsx.addCustomMessageHandler(debuggerInputMessageHandler); + } return lastRootComponentsManager; } diff --git a/src/valdi_modules/src/valdi/valdi_core/src/debugging/DebuggerInputMessageHandler.ts b/src/valdi_modules/src/valdi/valdi_core/src/debugging/DebuggerInputMessageHandler.ts new file mode 100644 index 000000000..c4403a376 --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_core/src/debugging/DebuggerInputMessageHandler.ts @@ -0,0 +1,1232 @@ +import { TouchEventState } from 'valdi_tsx/src/GestureEvents'; +import type { ElementFrame } from 'valdi_tsx/src/Geometry'; +import type { IRenderedElement } from '../IRenderedElement'; +import type { IRenderedVirtualNode } from '../IRenderedVirtualNode'; +import type { IRenderer } from '../IRenderer'; +import type { CustomMessageHandler } from './CustomMessageHandler'; + +const DEBUGGER_INPUT_IDENTIFIER = 'ValdiDebuggerInput'; +const DEBUGGER_INPUT_CONTRACT_VERSION = 1; +const MAX_DEBUGGER_INPUT_TRAVERSAL_NODES = 20000; + +export enum DebuggerInputType { + Capabilities = 'capabilities', + Query = 'query', + Tap = 'tap', + Focus = 'focus', + Text = 'text', + Key = 'key', + Scroll = 'scroll', +} + +const SUPPORTED_DEBUGGER_INPUT_TYPES: ReadonlyArray = [ + DebuggerInputType.Capabilities, + DebuggerInputType.Query, + DebuggerInputType.Tap, + DebuggerInputType.Focus, + DebuggerInputType.Text, + DebuggerInputType.Key, + DebuggerInputType.Scroll, +]; +const SUPPORTED_DEBUGGER_INPUT_TYPE_SET: ReadonlySet = new Set(SUPPORTED_DEBUGGER_INPUT_TYPES); +const DEBUGGER_INPUT_SELECTOR_FIELDS: ReadonlySet = new Set(['elementId', 'accessibilityId', 'tag']); +const DEBUGGER_INPUT_COMMON_FIELDS: ReadonlyArray = [ + 'type', + 'contextId', + 'elementId', + 'accessibilityId', + 'selector', +]; +const DEBUGGER_INPUT_FIELDS_BY_TYPE: ReadonlyMap> = new Map([ + [DebuggerInputType.Capabilities, new Set(['type', 'contextId'])], + [DebuggerInputType.Query, new Set(DEBUGGER_INPUT_COMMON_FIELDS)], + [DebuggerInputType.Tap, new Set([...DEBUGGER_INPUT_COMMON_FIELDS, 'x', 'y'])], + [DebuggerInputType.Focus, new Set([...DEBUGGER_INPUT_COMMON_FIELDS, 'focused'])], + [ + DebuggerInputType.Text, + new Set([...DEBUGGER_INPUT_COMMON_FIELDS, 'text', 'value', 'selectionStart', 'selectionEnd']), + ], + [DebuggerInputType.Key, new Set([...DEBUGGER_INPUT_COMMON_FIELDS, 'key', 'selectionStart', 'selectionEnd'])], + [DebuggerInputType.Scroll, new Set([...DEBUGGER_INPUT_COMMON_FIELDS, 'x', 'y', 'deltaX', 'deltaY'])], +]); + +interface DebuggerInputSelector { + accessibilityId?: string; + elementId?: number; + tag?: string; +} + +interface DebuggerInputRequest { + type?: string; + contextId?: string; + elementId?: number; + accessibilityId?: string; + selector?: string | DebuggerInputSelector; + focused?: boolean; + text?: string; + value?: string; + key?: string; + selectionStart?: number; + selectionEnd?: number; + x?: number; + y?: number; + deltaX?: number; + deltaY?: number; +} + +interface EditTextEvent { + text: string; + selectionStart: number; + selectionEnd: number; +} + +interface ElementMatch { + element: IRenderedElement; +} + +interface ElementCollection { + elements: ElementMatch[]; +} + +interface ElementPosition { + x: number; + y: number; +} + +interface TapTarget { + element: IRenderedElement; + callback: AttributeCallback; +} + +interface ParentChainEntry { + element: IRenderedElement; + parent: IRenderedElement | undefined; +} + +interface ParentFacts { + parent: IRenderedElement | undefined; + absolutePosition: ElementPosition; + descendantOrigin: ElementPosition; + blocker: InteractionBlocker | undefined; + tapTarget: TapTarget | undefined; + textInput: IRenderedElement | undefined; + scrollElement: IRenderedElement | undefined; +} + +interface SegmenterPart { + index: number; +} + +interface SegmenterLike { + segment(value: string): Iterable; +} + +interface SegmenterConstructor { + new (locales: undefined, options: { granularity: string }): SegmenterLike; +} + +enum InteractionBlockReason { + Disabled, + TouchDisabled, +} + +interface InteractionBlocker { + element: IRenderedElement; + reason: InteractionBlockReason; +} + +export interface DebuggerElementDescriptor { + elementId: number; + parentElementId?: number; + tag: string; + accessibilityId?: string; + accessibilityCategory?: string; + accessibilityNavigation?: string; + accessibilityLabel?: string; + accessibilityHint?: string; + accessibilityValue?: string; + selected: boolean; + enabled: boolean; + focused: boolean; + frame: ElementFrame; + absoluteFrame: ElementFrame; + actions: string[]; +} + +interface DebuggerInputResult { + contractVersion: number; + handled: boolean; + type: string; + contextId?: string; + elementId?: number; + accessibilityId?: string; + action?: string; + actionElementId?: number; + message?: string; + value?: string; + selectionStart?: number; + selectionEnd?: number; + contentOffsetX?: number; + contentOffsetY?: number; + elements?: DebuggerElementDescriptor[]; + supportedTypes?: string[]; + selectorForms?: string[]; +} + +type AttributeCallback = (event: any) => any; + +function asFiniteNumber(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function validateOptionalStringField( + values: Record, + fieldName: string, + displayName: string, + requireNonEmpty: boolean, +): string | undefined { + const value = values[fieldName]; + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + return `${displayName} must be a string.`; + } + if (requireNonEmpty && !value.length) { + return `${displayName} must not be empty.`; + } + if (containsLoneSurrogate(value)) { + return `${displayName} must contain valid Unicode.`; + } + return undefined; +} + +function validateOptionalNumberField( + values: Record, + fieldName: string, + displayName: string, + requireInteger: boolean, +): string | undefined { + const value = values[fieldName]; + if (value === undefined) { + return undefined; + } + if (typeof value !== 'number' || !Number.isFinite(value) || (requireInteger && !Number.isInteger(value))) { + return `${displayName} must be a finite ${requireInteger ? 'integer' : 'number'}.`; + } + return undefined; +} + +function validateSelector(request: DebuggerInputRequest): string | undefined { + const values = request as Record; + const selectorCount = [values['elementId'], values['accessibilityId'], values['selector']].filter( + value => value !== undefined, + ).length; + if (selectorCount > 1) { + return 'Use only one of elementId, accessibilityId, or selector.'; + } + + const elementIdError = validateOptionalNumberField(values, 'elementId', 'elementId', true); + if (elementIdError) { + return elementIdError; + } + const accessibilityIdError = validateOptionalStringField(values, 'accessibilityId', 'accessibilityId', true); + if (accessibilityIdError) { + return accessibilityIdError; + } + + const selector = values['selector']; + if (selector === undefined) { + return undefined; + } + if (typeof selector === 'string') { + return validateOptionalStringField(values, 'selector', 'selector', true); + } + if (!isRecord(selector)) { + return 'selector must be a string or an object.'; + } + const unknownField = Object.keys(selector) + .sort() + .find(fieldName => !DEBUGGER_INPUT_SELECTOR_FIELDS.has(fieldName)); + if (unknownField) { + return `Unsupported selector field '${unknownField}'.`; + } + if (!Object.keys(selector).some(fieldName => selector[fieldName] !== undefined)) { + return 'selector object must include elementId, accessibilityId, or tag.'; + } + + return ( + validateOptionalNumberField(selector, 'elementId', 'selector.elementId', true) ?? + validateOptionalStringField(selector, 'accessibilityId', 'selector.accessibilityId', true) ?? + validateOptionalStringField(selector, 'tag', 'selector.tag', true) + ); +} + +function validateSelection(request: DebuggerInputRequest): string | undefined { + const values = request as Record; + return ( + validateOptionalNumberField(values, 'selectionStart', 'selectionStart', true) ?? + validateOptionalNumberField(values, 'selectionEnd', 'selectionEnd', true) + ); +} + +function validateCoordinates(request: DebuggerInputRequest): string | undefined { + const values = request as Record; + return validateOptionalNumberField(values, 'x', 'x', false) ?? validateOptionalNumberField(values, 'y', 'y', false); +} + +function validateRequest(request: DebuggerInputRequest): string | undefined { + const values = request as Record; + if (typeof values['type'] !== 'string') { + return 'Debugger input type must be a string.'; + } + if (!SUPPORTED_DEBUGGER_INPUT_TYPE_SET.has(values['type'])) { + return `Unsupported debugger input type '${values['type']}'.`; + } + const type = values['type'] as DebuggerInputType; + const supportedFields = DEBUGGER_INPUT_FIELDS_BY_TYPE.get(type)!; + const unknownField = Object.keys(values) + .sort() + .find(fieldName => !supportedFields.has(fieldName)); + if (unknownField) { + return `Field '${unknownField}' is not supported for ${type} input.`; + } + + const contextIdError = validateOptionalStringField(values, 'contextId', 'contextId', true); + if (contextIdError) { + return contextIdError; + } + const selectorError = validateSelector(request); + if (selectorError) { + return selectorError; + } + + if (values['focused'] !== undefined && typeof values['focused'] !== 'boolean') { + return 'focused must be a boolean.'; + } + const textError = validateOptionalStringField(values, 'text', 'text', false); + if (textError) { + return textError; + } + const valueError = validateOptionalStringField(values, 'value', 'value', false); + if (valueError) { + return valueError; + } + const keyError = validateOptionalStringField(values, 'key', 'key', true); + if (keyError) { + return keyError; + } + const selectionError = validateSelection(request); + if (selectionError) { + return selectionError; + } + const coordinateError = validateCoordinates(request); + if (coordinateError) { + return coordinateError; + } + const deltaError = + validateOptionalNumberField(values, 'deltaX', 'deltaX', false) ?? + validateOptionalNumberField(values, 'deltaY', 'deltaY', false); + if (deltaError) { + return deltaError; + } + + if (request.type === DebuggerInputType.Text && values['text'] === undefined && values['value'] === undefined) { + return 'Text input requires a string text or value.'; + } + if (request.type === DebuggerInputType.Text && values['text'] !== undefined && values['value'] !== undefined) { + return 'Use only one of text or value.'; + } + if (request.type === DebuggerInputType.Key && values['key'] === undefined) { + return 'Key input requires a string key.'; + } + return undefined; +} + +function getAttributeCallback(element: IRenderedElement, name: string): AttributeCallback | undefined { + const value = element.getAttribute(name); + return typeof value === 'function' ? value : undefined; +} + +class DebuggerTraversalFailure extends Error {} + +class DebuggerInputTraversal { + private remainingWork = MAX_DEBUGGER_INPUT_TRAVERSAL_NODES; + private readonly admittedVirtualNodes = new Set(); + private readonly representedElements = new Set(); + private readonly admittedParentElements = new Set(); + private readonly parentFacts = new Map(); + + collectElements(renderer: IRenderer): ElementMatch[] { + const root = renderer.getRootVirtualNode(); + if (!root) { + return []; + } + this.reserveWork(1); + this.admitVirtualNode(root); + const elements: ElementMatch[] = []; + const pending: IRenderedVirtualNode[] = [root]; + while (pending.length) { + const node = pending.pop()!; + const childCount = node.children.length; + this.reserveWork(childCount); + if (node.element) { + elements.push({ element: node.element }); + } + for (let index = childCount - 1; index >= 0; index -= 1) { + const child = node.children[index]!; + this.admitVirtualNode(child); + pending.push(child); + } + } + return elements; + } + + factsFor(element: IRenderedElement): ParentFacts { + const existingFacts = this.parentFacts.get(element); + if (existingFacts) { + return existingFacts; + } + + const chain: ParentChainEntry[] = []; + const chainElements = new Set(); + let current: IRenderedElement | undefined = element; + let inheritedFacts: ParentFacts | undefined; + while (current) { + const cachedFacts = this.parentFacts.get(current); + if (cachedFacts) { + inheritedFacts = cachedFacts; + break; + } + if (chainElements.has(current)) { + throw new DebuggerTraversalFailure('Debugger input element ancestry contains a cycle.'); + } + chainElements.add(current); + this.admitParentElement(current); + const parent: IRenderedElement | undefined = current.parent; + chain.push({ element: current, parent }); + current = parent; + } + + for (let index = chain.length - 1; index >= 0; index -= 1) { + const entry = chain[index]!; + inheritedFacts = this.makeParentFacts(entry, inheritedFacts); + this.parentFacts.set(entry.element, inheritedFacts); + } + return this.parentFacts.get(element)!; + } + + private reserveWork(work: number): void { + if (work > this.remainingWork) { + throw new DebuggerTraversalFailure( + `Debugger input traversal exceeds the ${MAX_DEBUGGER_INPUT_TRAVERSAL_NODES}-node work limit.`, + ); + } + this.remainingWork -= work; + } + + private admitVirtualNode(node: IRenderedVirtualNode): void { + if (this.admittedVirtualNodes.has(node)) { + throw new DebuggerTraversalFailure('Debugger input render tree contains a cycle or repeated node.'); + } + this.admittedVirtualNodes.add(node); + if (node.element) { + this.representedElements.add(node.element); + } + } + + private admitParentElement(element: IRenderedElement): void { + if (this.representedElements.has(element) || this.admittedParentElements.has(element)) { + return; + } + this.reserveWork(1); + this.admittedParentElements.add(element); + } + + private makeParentFacts(entry: ParentChainEntry, parentFacts: ParentFacts | undefined): ParentFacts { + const { element, parent } = entry; + const frame = element.frame; + const hasFrame = frame !== undefined; + const parentOrigin = parentFacts?.descendantOrigin; + const absolutePosition = hasFrame + ? { + x: + frame.x + + asFiniteNumber(element.getAttribute('translationX'), 0) + + (parentOrigin === undefined ? 0 : parentOrigin.x), + y: + frame.y + + asFiniteNumber(element.getAttribute('translationY'), 0) + + (parentOrigin === undefined ? 0 : parentOrigin.y), + } + : { x: 0, y: 0 }; + const ownBlocker = interactionBlockerForElement(element); + const onTap = getAttributeCallback(element, 'onTap'); + return { + parent, + absolutePosition, + descendantOrigin: hasFrame + ? { + x: absolutePosition.x - asFiniteNumber(element.getAttribute('contentOffsetX'), 0), + y: absolutePosition.y - asFiniteNumber(element.getAttribute('contentOffsetY'), 0), + } + : { x: 0, y: 0 }, + blocker: ownBlocker ?? parentFacts?.blocker, + tapTarget: onTap ? { element, callback: onTap } : parentFacts?.tapTarget, + textInput: element.tag === 'textfield' || element.tag === 'textview' ? element : parentFacts?.textInput, + scrollElement: isScrollElement(element) ? element : parentFacts?.scrollElement, + }; + } +} + +function selectorFromRequest(request: DebuggerInputRequest): DebuggerInputSelector | undefined { + const selector = request.selector; + if (typeof selector === 'object' && selector !== null) { + return selector; + } + if (typeof selector === 'string') { + const accessibilityAttributeMatch = selector.match(/^\[accessibilityId=(?:"([^"]+)"|'([^']+)')\]$/); + if (accessibilityAttributeMatch) { + return { accessibilityId: accessibilityAttributeMatch[1] ?? accessibilityAttributeMatch[2] }; + } + return { accessibilityId: selector.startsWith('#') ? selector.slice(1) : selector }; + } + if (request.accessibilityId !== undefined) { + return { accessibilityId: request.accessibilityId }; + } + if (request.elementId !== undefined) { + return { elementId: request.elementId }; + } + return undefined; +} + +function matchesSelector(element: IRenderedElement, selector: DebuggerInputSelector): boolean { + if (selector.elementId !== undefined && element.id !== selector.elementId) { + return false; + } + if (selector.accessibilityId !== undefined && element.getAttribute('accessibilityId') !== selector.accessibilityId) { + return false; + } + return selector.tag === undefined || element.tag === selector.tag; +} + +function getElementsForSelector( + renderer: IRenderer, + selector: DebuggerInputSelector | undefined, + traversal: DebuggerInputTraversal, +): ElementCollection { + if (selector?.elementId !== undefined) { + const element = renderer.getElementForId(selector.elementId); + return { + elements: element && matchesSelector(element, selector) ? [{ element }] : [], + }; + } + + const elements = traversal.collectElements(renderer); + if (!selector) { + return { elements }; + } + return { + elements: elements.filter(match => matchesSelector(match.element, selector)), + }; +} + +function actionsForElement(element: IRenderedElement): string[] { + const actions: string[] = []; + if (getAttributeCallback(element, 'onTap')) { + actions.push(DebuggerInputType.Tap); + } + if (element.tag === 'textfield' || element.tag === 'textview') { + actions.push(DebuggerInputType.Focus, DebuggerInputType.Text, DebuggerInputType.Key); + } + if (isScrollElement(element)) { + actions.push(DebuggerInputType.Scroll); + } + return actions; +} + +function describeElement(element: IRenderedElement, facts: ParentFacts): DebuggerElementDescriptor { + const absolutePosition = facts.absolutePosition; + const value = asString(element.getAttribute('value')); + return { + elementId: element.id, + parentElementId: facts.parent?.id, + tag: element.tag, + accessibilityId: asString(element.getAttribute('accessibilityId')), + accessibilityCategory: asString(element.getAttribute('accessibilityCategory')), + accessibilityNavigation: asString(element.getAttribute('accessibilityNavigation')), + accessibilityLabel: asString(element.getAttribute('accessibilityLabel')), + accessibilityHint: asString(element.getAttribute('accessibilityHint')), + accessibilityValue: asString(element.getAttribute('accessibilityValue')) ?? value, + selected: element.getAttribute('accessibilityStateSelected') === true, + enabled: facts.blocker === undefined, + focused: element.getAttribute('focused') === true, + frame: element.frame, + absoluteFrame: { + x: absolutePosition.x, + y: absolutePosition.y, + width: element.frame.width, + height: element.frame.height, + }, + actions: actionsForElement(element), + }; +} + +function makeResult(request: DebuggerInputRequest, handled: boolean): DebuggerInputResult { + return { + contractVersion: DEBUGGER_INPUT_CONTRACT_VERSION, + handled, + type: typeof request.type === 'string' ? request.type : '', + contextId: typeof request.contextId === 'string' ? request.contextId : undefined, + }; +} + +function interactionBlockerForElement(element: IRenderedElement): InteractionBlocker | undefined { + if (element.getAttribute('enabled') === false || element.getAttribute('accessibilityStateDisabled') === true) { + return { element, reason: InteractionBlockReason.Disabled }; + } + if (element.getAttribute('touchEnabled') === false) { + return { element, reason: InteractionBlockReason.TouchDisabled }; + } + return undefined; +} + +function isScrollElement(element: IRenderedElement): boolean { + return ( + element.tag === 'scroll' || + element.getAttribute('contentOffsetX') !== undefined || + element.getAttribute('contentOffsetY') !== undefined + ); +} + +function makeInteractionBlockedResult( + request: DebuggerInputRequest, + selectedElement: IRenderedElement, + actionElement: IRenderedElement, + blocker: InteractionBlocker, +): DebuggerInputResult { + const message = + blocker.reason === InteractionBlockReason.TouchDisabled + ? `Element ${blocker.element.id} has touchEnabled=false.` + : `Element ${blocker.element.id} is disabled.`; + return { + ...makeResult(request, false), + elementId: selectedElement.id, + actionElementId: actionElement.id, + message, + }; +} + +function makeEditTextEvent(element: IRenderedElement, request: DebuggerInputRequest, text: string): EditTextEvent { + const selection = element.getAttribute('selection'); + const selectionStartFallback = Array.isArray(selection) ? asFiniteNumber(selection[0], text.length) : text.length; + const selectionEndFallback = Array.isArray(selection) ? asFiniteNumber(selection[1], text.length) : text.length; + let selectionStart = Math.max( + 0, + Math.min(text.length, Math.trunc(asFiniteNumber(request.selectionStart, selectionStartFallback))), + ); + let selectionEnd = Math.max( + selectionStart, + Math.min(text.length, Math.trunc(asFiniteNumber(request.selectionEnd, selectionEndFallback))), + ); + + if (selectionStart === selectionEnd) { + selectionStart = boundaryAtOrBefore(text, selectionStart); + selectionEnd = selectionStart; + } else { + selectionStart = boundaryAtOrBefore(text, selectionStart); + selectionEnd = boundaryAtOrAfter(text, selectionEnd); + } + return { text, selectionStart, selectionEnd }; +} + +function isHighSurrogate(codeUnit: number): boolean { + return codeUnit >= 0xd800 && codeUnit <= 0xdbff; +} + +function isLowSurrogate(codeUnit: number): boolean { + return codeUnit >= 0xdc00 && codeUnit <= 0xdfff; +} + +function containsLoneSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (isHighSurrogate(codeUnit)) { + if (index + 1 >= value.length || !isLowSurrogate(value.charCodeAt(index + 1))) { + return true; + } + index += 1; + } else if (isLowSurrogate(codeUnit)) { + return true; + } + } + return false; +} + +function isCombiningCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0x0300 && codePoint <= 0x036f) || + (codePoint >= 0x0483 && codePoint <= 0x0489) || + (codePoint >= 0x0591 && codePoint <= 0x05bd) || + codePoint === 0x05bf || + (codePoint >= 0x05c1 && codePoint <= 0x05c2) || + (codePoint >= 0x0610 && codePoint <= 0x061a) || + (codePoint >= 0x064b && codePoint <= 0x065f) || + (codePoint >= 0x0670 && codePoint <= 0x0670) || + (codePoint >= 0x06d6 && codePoint <= 0x06ed) || + (codePoint >= 0x1ab0 && codePoint <= 0x1aff) || + (codePoint >= 0x1dc0 && codePoint <= 0x1dff) || + (codePoint >= 0x20d0 && codePoint <= 0x20ff) || + (codePoint >= 0xfe20 && codePoint <= 0xfe2f) + ); +} + +function isGraphemeExtension(codePoint: number): boolean { + return ( + isCombiningCodePoint(codePoint) || + (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || + (codePoint >= 0xe0100 && codePoint <= 0xe01ef) || + (codePoint >= 0x1f3fb && codePoint <= 0x1f3ff) || + codePoint === 0x20e3 + ); +} + +function isRegionalIndicator(codePoint: number): boolean { + return codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff; +} + +function visitFallbackGraphemeBoundaries(value: string, visitor: (index: number) => boolean): void { + if (!visitor(0) || !value.length) { + return; + } + + let previousCodePoint = value.codePointAt(0)!; + let currentIndex = previousCodePoint > 0xffff ? 2 : 1; + let regionalIndicatorCount = isRegionalIndicator(previousCodePoint) ? 1 : 0; + while (currentIndex < value.length) { + const currentCodePoint = value.codePointAt(currentIndex)!; + const joinsPrevious = + (previousCodePoint === 0x000d && currentCodePoint === 0x000a) || + isGraphemeExtension(currentCodePoint) || + previousCodePoint === 0x200d || + currentCodePoint === 0x200d || + (isRegionalIndicator(previousCodePoint) && + isRegionalIndicator(currentCodePoint) && + regionalIndicatorCount % 2 === 1); + if (!joinsPrevious && !visitor(currentIndex)) { + return; + } + if (isRegionalIndicator(currentCodePoint)) { + regionalIndicatorCount = isRegionalIndicator(previousCodePoint) ? regionalIndicatorCount + 1 : 1; + } else if (!isGraphemeExtension(currentCodePoint)) { + regionalIndicatorCount = 0; + } + previousCodePoint = currentCodePoint; + currentIndex += currentCodePoint > 0xffff ? 2 : 1; + } + visitor(value.length); +} + +function visitGraphemeBoundaries(value: string, visitor: (index: number) => boolean): void { + const segmenterConstructor = + typeof Intl === 'undefined' ? undefined : (Intl as unknown as { Segmenter?: SegmenterConstructor }).Segmenter; + if (segmenterConstructor) { + try { + const segmenter = new segmenterConstructor(undefined, { granularity: 'grapheme' }); + let lastBoundary = -1; + for (const part of segmenter.segment(value)) { + lastBoundary = part.index; + if (!visitor(part.index)) { + return; + } + } + if (lastBoundary < 0 && !visitor(0)) { + return; + } + if (lastBoundary < 0) { + lastBoundary = 0; + } + if (lastBoundary !== value.length) { + visitor(value.length); + } + return; + } catch { + // Older Valdi runtimes may expose Intl without Segmenter support for grapheme granularity. + } + } + visitFallbackGraphemeBoundaries(value, visitor); +} + +function boundaryAtOrBefore(value: string, index: number): number { + let candidate = 0; + visitGraphemeBoundaries(value, boundary => { + if (boundary > index) { + return false; + } + candidate = boundary; + return true; + }); + return candidate; +} + +function boundaryAtOrAfter(value: string, index: number): number { + let candidate = value.length; + visitGraphemeBoundaries(value, boundary => { + if (boundary >= index) { + candidate = boundary; + return false; + } + return true; + }); + return candidate; +} + +function previousGraphemeBoundary(value: string, index: number): number { + let previousBoundary = 0; + visitGraphemeBoundaries(value, boundary => { + if (boundary >= index) { + return false; + } + previousBoundary = boundary; + return true; + }); + return previousBoundary; +} + +function nextGraphemeBoundary(value: string, index: number): number { + return boundaryAtOrAfter(value, index + 1); +} + +function isSinglePrintableGrapheme(key: string): boolean { + if (!key.length || containsLoneSurrogate(key)) { + return false; + } + let boundaryCount = 0; + visitGraphemeBoundaries(key, () => { + boundaryCount += 1; + return boundaryCount <= 2; + }); + if (boundaryCount !== 2) { + return false; + } + let index = 0; + while (index < key.length) { + const codePoint = key.codePointAt(index)!; + if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) { + return false; + } + index += codePoint > 0xffff ? 2 : 1; + } + return true; +} + +function closesWhenReturnKeyPressed(element: IRenderedElement): boolean { + const configuredValue = element.getAttribute('closesWhenReturnKeyPressed'); + return typeof configuredValue === 'boolean' ? configuredValue : element.tag === 'textfield'; +} + +function applyText( + element: IRenderedElement, + request: DebuggerInputRequest, + requestedText: string, +): DebuggerInputResult { + let event = makeEditTextEvent(element, request, requestedText); + const onWillChange = getAttributeCallback(element, 'onWillChange'); + if (onWillChange) { + const replacement = onWillChange(event); + if (isRecord(replacement) && replacement['text'] !== undefined) { + if (typeof replacement['text'] !== 'string' || containsLoneSurrogate(replacement['text'])) { + return { + ...makeResult(request, false), + elementId: element.id, + actionElementId: element.id, + message: `Element ${element.id} returned invalid Unicode text from onWillChange.`, + }; + } + const replacementRequest = replacement as DebuggerInputRequest; + const replacementSelectionError = validateSelection(replacementRequest); + if (replacementSelectionError) { + return { + ...makeResult(request, false), + elementId: element.id, + actionElementId: element.id, + message: `Element ${element.id} returned invalid onWillChange selection: ${replacementSelectionError}`, + }; + } + event = makeEditTextEvent(element, replacementRequest, replacement['text']); + } + } + + element.setAttributes({ + value: event.text, + selection: [event.selectionStart, event.selectionEnd], + }); + const onChange = getAttributeCallback(element, 'onChange'); + if (onChange) { + onChange(event); + } + + return { + ...makeResult(request, true), + elementId: element.id, + accessibilityId: asString(element.getAttribute('accessibilityId')), + action: 'onChange', + actionElementId: element.id, + value: event.text, + selectionStart: event.selectionStart, + selectionEnd: event.selectionEnd, + }; +} + +function applyKey(element: IRenderedElement, request: DebuggerInputRequest): DebuggerInputResult { + const key = request.key as string; + const currentText = asString(element.getAttribute('value')) ?? ''; + if (containsLoneSurrogate(currentText)) { + return { + ...makeResult(request, false), + elementId: element.id, + actionElementId: element.id, + message: `Element ${element.id} contains invalid Unicode text.`, + }; + } + const currentEvent = makeEditTextEvent(element, request, currentText); + + if (key === 'Enter' || key === 'Return') { + const onReturn = getAttributeCallback(element, 'onReturn'); + let finalEvent = currentEvent; + let insertedNewline = false; + if (element.tag === 'textview' && element.getAttribute('ignoreNewlines') !== true) { + const nextText = + currentText.slice(0, currentEvent.selectionStart) + '\n' + currentText.slice(currentEvent.selectionEnd); + const caret = currentEvent.selectionStart + 1; + const textResult = applyText(element, { ...request, selectionStart: caret, selectionEnd: caret }, nextText); + if (!textResult.handled) { + return textResult; + } + finalEvent = { + text: textResult.value ?? nextText, + selectionStart: textResult.selectionStart ?? caret, + selectionEnd: textResult.selectionEnd ?? caret, + }; + insertedNewline = true; + } + const closesOnReturn = closesWhenReturnKeyPressed(element); + if (closesOnReturn) { + element.setAttribute('focused', false); + } + if (onReturn) { + onReturn(finalEvent); + } + return { + ...makeResult(request, true), + elementId: element.id, + accessibilityId: asString(element.getAttribute('accessibilityId')), + action: onReturn ? 'onReturn' : insertedNewline ? 'onChange' : closesOnReturn ? 'focused' : 'return', + actionElementId: element.id, + value: finalEvent.text, + selectionStart: finalEvent.selectionStart, + selectionEnd: finalEvent.selectionEnd, + }; + } + + if (key === 'Escape') { + element.setAttribute('focused', false); + return { + ...makeResult(request, true), + elementId: element.id, + accessibilityId: asString(element.getAttribute('accessibilityId')), + action: 'focused', + actionElementId: element.id, + value: currentText, + }; + } + + if (key === 'Backspace' || key === 'Delete') { + const onWillDelete = getAttributeCallback(element, 'onWillDelete'); + if (onWillDelete) { + onWillDelete(currentEvent); + } + + let start = currentEvent.selectionStart; + let end = currentEvent.selectionEnd; + if (start === end) { + if (key === 'Backspace' && start > 0) { + start = previousGraphemeBoundary(currentText, start); + } else if (key === 'Delete' && end < currentText.length) { + end = nextGraphemeBoundary(currentText, end); + } + } + const nextText = currentText.slice(0, start) + currentText.slice(end); + return applyText(element, { ...request, selectionStart: start, selectionEnd: start }, nextText); + } + + if (isSinglePrintableGrapheme(key)) { + const nextText = + currentText.slice(0, currentEvent.selectionStart) + key + currentText.slice(currentEvent.selectionEnd); + const caret = currentEvent.selectionStart + key.length; + return applyText(element, { ...request, selectionStart: caret, selectionEnd: caret }, nextText); + } + + return { + ...makeResult(request, false), + elementId: element.id, + message: `Unsupported key '${key}'.`, + }; +} + +export class DebuggerInputMessageHandler implements CustomMessageHandler { + constructor(private readonly getRendererForContextId: (contextId: string) => IRenderer | undefined) {} + + messageReceived(identifier: string, body: any): Promise | undefined { + if (identifier !== DEBUGGER_INPUT_IDENTIFIER) { + return undefined; + } + if (!isRecord(body)) { + return Promise.resolve({ + ...makeResult({}, false), + message: 'Debugger input request must be an object.', + }); + } + const request = body as DebuggerInputRequest; + return Promise.resolve().then(() => this.handle(request)); + } + + private handle(request: DebuggerInputRequest): DebuggerInputResult { + const validationError = validateRequest(request); + if (validationError) { + return { + ...makeResult(request, false), + message: validationError, + }; + } + + if (request.type === DebuggerInputType.Capabilities) { + return { + ...makeResult(request, true), + action: DebuggerInputType.Capabilities, + supportedTypes: SUPPORTED_DEBUGGER_INPUT_TYPES.slice(), + selectorForms: [ + 'elementId', + 'accessibilityId', + 'selector: "#accessibilityId"', + 'selector: "[accessibilityId=\\"accessibilityId\\"]"', + 'selector: { elementId?, accessibilityId?, tag? }', + ], + }; + } + + if (!request.contextId) { + return { + ...makeResult(request, false), + message: 'A contextId is required.', + }; + } + const renderer = this.getRendererForContextId(request.contextId); + if (!renderer) { + return { + ...makeResult(request, false), + message: `No Valdi renderer found for context ${request.contextId}.`, + }; + } + + const traversal = new DebuggerInputTraversal(); + try { + const selector = selectorFromRequest(request); + const collection = getElementsForSelector(renderer, selector, traversal); + const matches = collection.elements; + + if (request.type === DebuggerInputType.Query) { + return { + ...makeResult(request, true), + action: DebuggerInputType.Query, + elements: matches.map(match => describeElement(match.element, traversal.factsFor(match.element))), + }; + } + + if (!selector) { + return { + ...makeResult(request, false), + message: 'An elementId, accessibilityId, or selector is required.', + }; + } + if (!matches.length) { + return { + ...makeResult(request, false), + message: 'No element matched the debugger input selector.', + }; + } + if (matches.length > 1) { + return { + ...makeResult(request, false), + message: `Debugger input selector matched ${matches.length} elements; use a unique accessibilityId or elementId.`, + elements: matches.map(match => describeElement(match.element, traversal.factsFor(match.element))), + }; + } + + const selectedElement = matches[0]!.element; + const selectedFacts = traversal.factsFor(selectedElement); + if (request.type === DebuggerInputType.Tap) { + const tapTarget = selectedFacts.tapTarget; + if (!tapTarget) { + return { + ...makeResult(request, false), + elementId: selectedElement.id, + message: `Element ${selectedElement.id} and its ancestors do not expose onTap.`, + }; + } + const blocker = selectedFacts.blocker; + if (blocker) { + return makeInteractionBlockedResult(request, selectedElement, tapTarget.element, blocker); + } + const position = traversal.factsFor(tapTarget.element).absolutePosition; + const absoluteX = request.x === undefined ? position.x + tapTarget.element.frame.width / 2 : request.x; + const absoluteY = request.y === undefined ? position.y + tapTarget.element.frame.height / 2 : request.y; + const localX = absoluteX - position.x; + const localY = absoluteY - position.y; + tapTarget.callback({ + state: TouchEventState.Ended, + x: localX, + y: localY, + absoluteX: position.x + localX, + absoluteY: position.y + localY, + pointerCount: 1, + pointerLocations: [{ pointerId: 0, x: localX, y: localY }], + eventTime: Date.now() / 1000, + }); + return { + ...makeResult(request, true), + elementId: selectedElement.id, + accessibilityId: asString(selectedElement.getAttribute('accessibilityId')), + action: 'onTap', + actionElementId: tapTarget.element.id, + }; + } + + if (request.type === DebuggerInputType.Focus) { + const textInput = selectedFacts.textInput; + if (!textInput) { + return { + ...makeResult(request, false), + elementId: selectedElement.id, + message: `Element ${selectedElement.id} is not a text input.`, + }; + } + const blocker = selectedFacts.blocker; + if (blocker) { + return makeInteractionBlockedResult(request, selectedElement, textInput, blocker); + } + const focused = request.focused !== false; + textInput.setAttribute('focused', focused); + return { + ...makeResult(request, true), + elementId: textInput.id, + accessibilityId: asString(textInput.getAttribute('accessibilityId')), + action: 'focused', + actionElementId: textInput.id, + }; + } + + if (request.type === DebuggerInputType.Text) { + const textInput = selectedFacts.textInput; + if (!textInput) { + return { + ...makeResult(request, false), + elementId: selectedElement.id, + message: `Element ${selectedElement.id} is not a text input.`, + }; + } + const blocker = selectedFacts.blocker; + if (blocker) { + return makeInteractionBlockedResult(request, selectedElement, textInput, blocker); + } + if (textInput.getAttribute('editable') === false) { + return { + ...makeResult(request, false), + elementId: selectedElement.id, + actionElementId: textInput.id, + message: `Element ${textInput.id} is not editable.`, + }; + } + textInput.setAttribute('focused', true); + return applyText(textInput, request, (request.text ?? request.value) as string); + } + + if (request.type === DebuggerInputType.Key) { + const textInput = selectedFacts.textInput; + if (!textInput) { + return { + ...makeResult(request, false), + elementId: selectedElement.id, + message: `Element ${selectedElement.id} is not a text input.`, + }; + } + const blocker = selectedFacts.blocker; + if (blocker) { + return makeInteractionBlockedResult(request, selectedElement, textInput, blocker); + } + if (textInput.getAttribute('editable') === false) { + return { + ...makeResult(request, false), + elementId: selectedElement.id, + actionElementId: textInput.id, + message: `Element ${textInput.id} is not editable.`, + }; + } + return applyKey(textInput, request); + } + + if (request.type === DebuggerInputType.Scroll) { + const scrollElement = selectedFacts.scrollElement; + if (!scrollElement) { + return { + ...makeResult(request, false), + elementId: selectedElement.id, + message: `Element ${selectedElement.id} is not in a scroll container.`, + }; + } + const blocker = selectedFacts.blocker; + if (blocker) { + return makeInteractionBlockedResult(request, selectedElement, scrollElement, blocker); + } + const contentOffsetX = asFiniteNumber(scrollElement.getAttribute('contentOffsetX'), 0) + (request.deltaX ?? 0); + const contentOffsetY = asFiniteNumber(scrollElement.getAttribute('contentOffsetY'), 0) + (request.deltaY ?? 0); + scrollElement.setAttributes({ + contentOffsetAnimated: false, + contentOffsetX, + contentOffsetY, + }); + return { + ...makeResult(request, true), + elementId: selectedElement.id, + accessibilityId: asString(selectedElement.getAttribute('accessibilityId')), + action: 'contentOffset', + actionElementId: scrollElement.id, + contentOffsetX, + contentOffsetY, + }; + } + + return { + ...makeResult(request, false), + elementId: selectedElement.id, + message: `Unsupported debugger input type '${request.type ?? ''}'.`, + }; + } catch (error) { + if (error instanceof DebuggerTraversalFailure) { + return { + ...makeResult(request, false), + message: error.message, + }; + } + throw error; + } + } +} diff --git a/src/valdi_modules/src/valdi/valdi_core/src/utils/RenderedElementUtils.ts b/src/valdi_modules/src/valdi/valdi_core/src/utils/RenderedElementUtils.ts index 18296abf9..6ebbeed3b 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/utils/RenderedElementUtils.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/utils/RenderedElementUtils.ts @@ -2,16 +2,46 @@ import { ElementFrame } from 'valdi_tsx/src/Geometry'; import { Point, Size } from '../Geometry'; import { IRenderedElement } from '../IRenderedElement'; +const MAX_RENDERED_ELEMENT_PARENT_WALK = 20000; + +enum ParentWalkStatus { + Completed, + Stopped, + Failed, +} + +function walkParents( + element: IRenderedElement | undefined, + visitor: (element: IRenderedElement) => boolean, +): ParentWalkStatus { + const visited = new Set(); + let current = element; + let remainingWork = MAX_RENDERED_ELEMENT_PARENT_WALK; + while (current) { + if (remainingWork === 0 || visited.has(current)) { + return ParentWalkStatus.Failed; + } + remainingWork -= 1; + visited.add(current); + if (!visitor(current)) { + return ParentWalkStatus.Stopped; + } + current = current.parent; + } + return ParentWalkStatus.Completed; +} + export namespace RenderedElementUtils { /** * Compute a relative position within the element tree by recursively looking through the parents */ export function relativePositionTo(parent: IRenderedElement, child: IRenderedElement): Point | undefined { - let current: IRenderedElement | undefined = child; const position = { x: 0, y: 0 }; - while (current) { + let found = false; + const status = walkParents(child, current => { if (current == parent) { - return position; + found = true; + return false; } const contentOffsetX = current.getAttribute('contentOffsetX') ?? 0; const contentOffsetY = current.getAttribute('contentOffsetY') ?? 0; @@ -19,9 +49,9 @@ export namespace RenderedElementUtils { const translationY = current.getAttribute('translationY') ?? 0; position.x += current.frame.x - contentOffsetX + translationX; position.y += current.frame.y - contentOffsetY + translationY; - current = current.parent; - } - return undefined; + return true; + }); + return found && status !== ParentWalkStatus.Failed ? position : undefined; } /** * Check if a relative position is within the bounds of a frame @@ -53,11 +83,12 @@ export namespace RenderedElementUtils { * Returns undefined if the element is undefined. */ export function rootElement(element: IRenderedElement | undefined): IRenderedElement | undefined { - let current = element; - while (current?.parent) { - current = current.parent; - } - return current; + let root: IRenderedElement | undefined; + const status = walkParents(element, current => { + root = current; + return true; + }); + return status === ParentWalkStatus.Failed ? undefined : root; } /** @@ -66,20 +97,14 @@ export namespace RenderedElementUtils { * Returns undefined if no element with a valid frame width is found. */ export function rootElementWithFrame(element: IRenderedElement | undefined): IRenderedElement | undefined { - let current = element; let lastWithValidWidth: IRenderedElement | undefined; - - while (current) { + const status = walkParents(element, current => { if (current.frame?.width) { lastWithValidWidth = current; } - if (!current.parent) { - break; - } - current = current.parent; - } - - return lastWithValidWidth; + return true; + }); + return status === ParentWalkStatus.Failed ? undefined : lastWithValidWidth; } /** @@ -89,11 +114,9 @@ export namespace RenderedElementUtils { */ export function absolutePosition(element: IRenderedElement | undefined): Point { const position = { x: 0, y: 0 }; - let current = element; - - while (current) { + const status = walkParents(element, current => { if (!current.frame) { - break; + return false; } // Only subtract contentOffset if it belongs to a parent (affecting the current element's position) // The element's own contentOffset does not affect its own absolute frame position @@ -104,9 +127,8 @@ export namespace RenderedElementUtils { const translationY = current.getAttribute('translationY') ?? 0; position.x += current.frame.x - contentOffsetX + translationX; position.y += current.frame.y - contentOffsetY + translationY; - current = current.parent; - } - - return position; + return true; + }); + return status === ParentWalkStatus.Failed ? { x: 0, y: 0 } : position; } } diff --git a/src/valdi_modules/src/valdi/valdi_test/test/DebuggerInputMessageHandler.spec.ts b/src/valdi_modules/src/valdi/valdi_test/test/DebuggerInputMessageHandler.spec.ts new file mode 100644 index 000000000..39a96be54 --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_test/test/DebuggerInputMessageHandler.spec.ts @@ -0,0 +1,1328 @@ +import { IRenderedElement } from 'valdi_core/src/IRenderedElement'; +import { IRenderedVirtualNode } from 'valdi_core/src/IRenderedVirtualNode'; +import { IRenderer } from 'valdi_core/src/IRenderer'; +import { DebuggerInputMessageHandler, DebuggerInputType } from 'valdi_core/src/debugging/DebuggerInputMessageHandler'; +import 'jasmine/src/jasmine'; + +const DEBUGGER_INPUT_IDENTIFIER = 'ValdiDebuggerInput'; + +class TestElement { + readonly viewClass = 'TestView'; + readonly key: string; + readonly children: IRenderedElement[] = []; + readonly frame = { x: 10, y: 20, width: 100, height: 40 }; + parent: IRenderedElement | undefined; + parentIndex = 0; + renderer: IRenderer = undefined!; + emittingComponent = undefined; + + constructor( + readonly id: number, + readonly tag: string, + private readonly attributes: { [name: string]: any }, + ) { + this.key = `${tag}-${id}`; + } + + getAttributeNames(): string[] { + return Object.keys(this.attributes); + } + + getAttribute(name: string): any { + return this.attributes[name]; + } + + setAttribute(name: string, value: any): boolean { + this.attributes[name] = value; + return true; + } + + setAttributes(attributes: { [name: string]: any }): boolean { + Object.keys(attributes).forEach(name => { + this.attributes[name] = attributes[name]; + }); + return true; + } + + getVirtualNode(): IRenderedVirtualNode { + throw new Error('Not implemented by debugger input test element.'); + } + + getNativeView(): Promise { + return Promise.resolve(undefined); + } + + getNativeNode(): undefined { + return undefined; + } + + takeSnapshot(): Promise { + return Promise.resolve(undefined); + } + + asRenderedElement(): IRenderedElement { + return this as unknown as IRenderedElement; + } +} + +function makeNode(element: TestElement | undefined, children: IRenderedVirtualNode[]): IRenderedVirtualNode { + return { + key: element?.key ?? 'root', + parent: undefined, + element: element?.asRenderedElement(), + component: undefined, + children, + parentIndex: 0, + uniqueId: element?.key ?? 'root', + } as IRenderedVirtualNode; +} + +function makeHandler(root: IRenderedVirtualNode): DebuggerInputMessageHandler { + const elementsById = new Map(); + const pending = [root]; + while (pending.length) { + const node = pending.pop()!; + if (node.element) { + elementsById.set(node.element.id, node.element); + } + pending.push(...node.children); + } + const renderer = { + getRootVirtualNode: () => root, + getElementForId: (elementId: number) => elementsById.get(elementId), + } as unknown as IRenderer; + return new DebuggerInputMessageHandler(contextId => (contextId === 'context-1' ? renderer : undefined)); +} + +async function send( + handler: DebuggerInputMessageHandler, + request: { [name: string]: any }, +): Promise<{ [name: string]: any }> { + const response = handler.messageReceived(DEBUGGER_INPUT_IDENTIFIER, request); + if (!response) { + throw new Error('Expected debugger input handler to accept the request.'); + } + return await response; +} + +function containsLoneSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + if (index + 1 >= value.length) { + return true; + } + const nextCodeUnit = value.charCodeAt(index + 1); + if (nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) { + return true; + } + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return true; + } + } + return false; +} + +describe('DebuggerInputMessageHandler', () => { + it('reports its stable contract and supported operations', async () => { + const handler = makeHandler(makeNode(undefined, [])); + + const response = await send(handler, { type: DebuggerInputType.Capabilities }); + + expect(response['handled']).toBeTrue(); + expect(response['contractVersion']).toBe(1); + expect(response['supportedTypes']).toContain(DebuggerInputType.Query); + expect(response['supportedTypes']).toContain(DebuggerInputType.Key); + }); + + it('queries elements by accessibilityId with typed automation metadata', async () => { + const input = new TestElement(7, 'textfield', { + accessibilityId: 'composer', + accessibilityCategory: 'input', + accessibilityLabel: 'Message', + value: 'hello', + focused: true, + accessibilityStateSelected: true, + }); + const handler = makeHandler(makeNode(undefined, [makeNode(input, [])])); + + const response = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'context-1', + selector: '#composer', + }); + + expect(response['handled']).toBeTrue(); + expect(response['elements']).toEqual([ + jasmine.objectContaining({ + elementId: 7, + tag: 'textfield', + accessibilityId: 'composer', + accessibilityCategory: 'input', + accessibilityLabel: 'Message', + accessibilityValue: 'hello', + selected: true, + enabled: true, + focused: true, + actions: jasmine.arrayContaining([DebuggerInputType.Focus, DebuggerInputType.Text, DebuggerInputType.Key]), + }), + ]); + }); + + it('resolves numeric element IDs through the renderer without traversing the tree', async () => { + const input = new TestElement(70, 'textfield', { + accessibilityId: 'direct-input', + value: 'direct', + }); + const getElementForId = jasmine.createSpy('getElementForId').and.returnValue(input.asRenderedElement()); + const getRootVirtualNode = jasmine.createSpy('getRootVirtualNode').and.callFake(() => { + throw new Error('numeric element lookup must not traverse the tree'); + }); + const renderer = { getElementForId, getRootVirtualNode } as unknown as IRenderer; + const handler = new DebuggerInputMessageHandler(contextId => (contextId === 'context-1' ? renderer : undefined)); + + const response = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'context-1', + selector: { elementId: 70, tag: 'textfield' }, + }); + + expect(response['handled']).toBeTrue(); + expect(response['elements']).toEqual([jasmine.objectContaining({ elementId: 70, tag: 'textfield' })]); + expect(getElementForId).toHaveBeenCalledOnceWith(70); + expect(getRootVirtualNode).not.toHaveBeenCalled(); + }); + + it('returns a structured failure for a cyclic direct-ID parent chain', async () => { + const first = new TestElement(72, 'view', {}); + const second = new TestElement(73, 'view', {}); + first.parent = second.asRenderedElement(); + second.parent = first.asRenderedElement(); + const getRootVirtualNode = jasmine.createSpy('getRootVirtualNode').and.callFake(() => { + throw new Error('numeric element lookup must not traverse the tree'); + }); + const renderer = { + getElementForId: () => first.asRenderedElement(), + getRootVirtualNode, + } as unknown as IRenderer; + const handler = new DebuggerInputMessageHandler(contextId => (contextId === 'context-1' ? renderer : undefined)); + + const response = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'context-1', + elementId: first.id, + }); + + expect(response).toEqual( + jasmine.objectContaining({ + handled: false, + message: 'Debugger input element ancestry contains a cycle.', + }), + ); + expect(getRootVirtualNode).not.toHaveBeenCalled(); + }); + + it('returns a structured failure for an over-deep direct-ID parent chain', async () => { + const target = new TestElement(74, 'view', {}); + let child = target; + for (let index = 0; index < 20001; index += 1) { + const parent = new TestElement(1000 + index, 'view', {}); + child.parent = parent.asRenderedElement(); + child = parent; + } + const getRootVirtualNode = jasmine.createSpy('getRootVirtualNode').and.callFake(() => { + throw new Error('numeric element lookup must not traverse the tree'); + }); + const renderer = { + getElementForId: () => target.asRenderedElement(), + getRootVirtualNode, + } as unknown as IRenderer; + const handler = new DebuggerInputMessageHandler(contextId => (contextId === 'context-1' ? renderer : undefined)); + + const response = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'context-1', + elementId: target.id, + }); + + expect(response).toEqual( + jasmine.objectContaining({ + handled: false, + message: 'Debugger input traversal exceeds the 20000-node work limit.', + }), + ); + expect(getRootVirtualNode).not.toHaveBeenCalled(); + }); + + it('queries a deeply nested tree without recursive traversal', async () => { + const target = new TestElement(71, 'view', { accessibilityId: 'deep-target' }); + let root = makeNode(target, []); + for (let index = 0; index < 12000; index += 1) { + root = makeNode(undefined, [root]); + } + const handler = makeHandler(root); + + const response = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'context-1', + accessibilityId: 'deep-target', + }); + + expect(response['handled']).toBeTrue(); + expect(response['elements']).toEqual([jasmine.objectContaining({ elementId: 71 })]); + }); + + it('describes every element in a deep tree with linear parent work', async () => { + const elementCount = 2000; + const elements: TestElement[] = []; + let parent: IRenderedElement | undefined; + let parentReadCount = 0; + for (let index = 0; index < elementCount; index += 1) { + const element = new TestElement(10000 + index, 'view', {}); + const capturedParent = parent; + Object.defineProperty(element, 'parent', { + configurable: true, + get: () => { + parentReadCount += 1; + return capturedParent; + }, + }); + elements.push(element); + parent = element.asRenderedElement(); + } + let root = makeNode(elements[elementCount - 1], []); + for (let index = elementCount - 2; index >= 0; index -= 1) { + root = makeNode(elements[index], [root]); + } + const handler = makeHandler(root); + + const response = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'context-1', + }); + + expect(response['handled']).toBeTrue(); + const descriptors = response['elements'] as Array<{ absoluteFrame: { x: number; y: number } }>; + expect(descriptors.length).toBe(elementCount); + expect(descriptors[elementCount - 1]!.absoluteFrame).toEqual(jasmine.objectContaining({ x: 20000, y: 40000 })); + expect(parentReadCount).toBeLessThanOrEqual(elementCount + 1); + }); + + it('memoizes absolute frames with parent offsets and translations', async () => { + const parent = new TestElement(76, 'view', { + contentOffsetX: 2, + contentOffsetY: 5, + translationX: 3, + translationY: 1, + }); + const child = new TestElement(77, 'view', { + accessibilityId: 'positioned-child', + translationX: 4, + translationY: 2, + }); + child.parent = parent.asRenderedElement(); + const handler = makeHandler(makeNode(parent, [makeNode(child, [])])); + + const response = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'context-1', + accessibilityId: 'positioned-child', + }); + + expect(response['elements']).toEqual([ + jasmine.objectContaining({ + elementId: child.id, + parentElementId: parent.id, + absoluteFrame: jasmine.objectContaining({ x: 25, y: 38 }), + }), + ]); + }); + + it('shares one work budget between virtual nodes and off-tree parents', async () => { + const target = new TestElement(75, 'view', { accessibilityId: 'globally-bounded' }); + let child = target; + for (let index = 0; index < 10000; index += 1) { + const parent = new TestElement(40000 + index, 'view', {}); + child.parent = parent.asRenderedElement(); + child = parent; + } + let root = makeNode(target, []); + for (let index = 0; index < 11000; index += 1) { + root = makeNode(undefined, [root]); + } + const handler = makeHandler(root); + + const response = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'context-1', + accessibilityId: 'globally-bounded', + }); + + expect(response).toEqual( + jasmine.objectContaining({ + handled: false, + message: 'Debugger input traversal exceeds the 20000-node work limit.', + }), + ); + }); + + it('returns a structured failure when selector traversal exceeds its node budget', async () => { + let root = makeNode(undefined, []); + for (let index = 0; index < 20001; index += 1) { + root = makeNode(undefined, [root]); + } + const handler = makeHandler(root); + + const response = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'context-1', + accessibilityId: 'missing', + }); + + expect(response['handled']).toBeFalse(); + expect(response['message']).toBe('Debugger input traversal exceeds the 20000-node work limit.'); + }); + + it('rejects an over-wide tree before reading or queuing its children', async () => { + let childReadCount = 0; + const children = new Proxy([] as IRenderedVirtualNode[], { + get(target, property): unknown { + if (property === 'length') return 20001; + childReadCount += 1; + throw new Error(`unexpected child read: ${String(property)}`); + }, + }); + const root = makeNode(undefined, children); + const renderer = { + getElementForId: () => undefined, + getRootVirtualNode: () => root, + } as unknown as IRenderer; + const handler = new DebuggerInputMessageHandler(contextId => (contextId === 'context-1' ? renderer : undefined)); + + const response = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'context-1', + accessibilityId: 'missing', + }); + + expect(response['handled']).toBeFalse(); + expect(response['message']).toBe('Debugger input traversal exceeds the 20000-node work limit.'); + expect(childReadCount).toBe(0); + }); + + it('rejects cyclic virtual trees without revisiting a node', async () => { + const root = makeNode(undefined, []); + root.children.push(root); + const renderer = { + getElementForId: () => undefined, + getRootVirtualNode: () => root, + } as unknown as IRenderer; + const handler = new DebuggerInputMessageHandler(contextId => (contextId === 'context-1' ? renderer : undefined)); + + const response = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'context-1', + accessibilityId: 'missing', + }); + + expect(response).toEqual( + jasmine.objectContaining({ + handled: false, + message: 'Debugger input render tree contains a cycle or repeated node.', + }), + ); + }); + + it('dispatches a tap through the nearest rendered onTap callback', async () => { + const onTap = jasmine.createSpy('onTap'); + const button = new TestElement(2, 'view', { accessibilityId: 'send', onTap }); + const label = new TestElement(3, 'label', { value: 'Send' }); + label.parent = button.asRenderedElement(); + button.children.push(label.asRenderedElement()); + const handler = makeHandler(makeNode(undefined, [makeNode(button, [makeNode(label, [])])])); + + const response = await send(handler, { + type: DebuggerInputType.Tap, + contextId: 'context-1', + accessibilityId: 'send', + }); + + expect(response).toEqual( + jasmine.objectContaining({ + handled: true, + elementId: 2, + action: 'onTap', + actionElementId: 2, + }), + ); + expect(onTap).toHaveBeenCalledTimes(1); + expect(onTap).toHaveBeenCalledWith( + jasmine.objectContaining({ + pointerCount: 1, + absoluteX: 60, + absoluteY: 40, + }), + ); + }); + + it('rejects taps when the target or an ancestor disables touch input', async () => { + const targetOnTap = jasmine.createSpy('targetOnTap'); + const target = new TestElement(19, 'view', { + accessibilityId: 'touch-disabled-target', + onTap: targetOnTap, + touchEnabled: false, + }); + const ancestorOnTap = jasmine.createSpy('ancestorOnTap'); + const ancestor = new TestElement(20, 'view', { + accessibilityId: 'touch-disabled-ancestor', + onTap: ancestorOnTap, + touchEnabled: false, + }); + const descendant = new TestElement(21, 'label', { accessibilityId: 'touch-disabled-descendant' }); + descendant.parent = ancestor.asRenderedElement(); + ancestor.children.push(descendant.asRenderedElement()); + const handler = makeHandler( + makeNode(undefined, [makeNode(target, []), makeNode(ancestor, [makeNode(descendant, [])])]), + ); + + const targetResponse = await send(handler, { + type: DebuggerInputType.Tap, + contextId: 'context-1', + accessibilityId: 'touch-disabled-target', + }); + const ancestorResponse = await send(handler, { + type: DebuggerInputType.Tap, + contextId: 'context-1', + accessibilityId: 'touch-disabled-descendant', + }); + + expect(targetResponse).toEqual( + jasmine.objectContaining({ + handled: false, + elementId: 19, + actionElementId: 19, + message: 'Element 19 has touchEnabled=false.', + }), + ); + expect(ancestorResponse).toEqual( + jasmine.objectContaining({ + handled: false, + elementId: 21, + actionElementId: 20, + message: 'Element 20 has touchEnabled=false.', + }), + ); + expect(targetOnTap).not.toHaveBeenCalled(); + expect(ancestorOnTap).not.toHaveBeenCalled(); + }); + + it('focuses and edits text inputs while preserving selection metadata', async () => { + const onChange = jasmine.createSpy('onChange'); + const input = new TestElement(4, 'textfield', { + accessibilityId: 'composer', + value: '', + onChange, + }); + const handler = makeHandler(makeNode(undefined, [makeNode(input, [])])); + + const response = await send(handler, { + type: DebuggerInputType.Text, + contextId: 'context-1', + selector: { accessibilityId: 'composer', tag: 'textfield' }, + text: 'draft', + selectionStart: 5, + selectionEnd: 5, + }); + + expect(response).toEqual( + jasmine.objectContaining({ + handled: true, + elementId: 4, + value: 'draft', + selectionStart: 5, + selectionEnd: 5, + }), + ); + expect(input.getAttribute('focused')).toBeTrue(); + expect(input.getAttribute('value')).toBe('draft'); + expect(input.getAttribute('selection')).toEqual([5, 5]); + expect(onChange).toHaveBeenCalledWith({ + text: 'draft', + selectionStart: 5, + selectionEnd: 5, + }); + }); + + it('dispatches return keys and relative scrolling', async () => { + const onReturn = jasmine.createSpy('onReturn'); + const input = new TestElement(5, 'textfield', { + accessibilityId: 'composer', + value: 'send this', + focused: true, + onReturn, + }); + const scroll = new TestElement(6, 'scroll', { + accessibilityId: 'messages', + contentOffsetX: 2, + contentOffsetY: 10, + }); + const handler = makeHandler(makeNode(undefined, [makeNode(input, []), makeNode(scroll, [])])); + + const keyResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'composer', + key: 'Enter', + }); + const scrollResponse = await send(handler, { + type: DebuggerInputType.Scroll, + contextId: 'context-1', + accessibilityId: 'messages', + deltaX: 3, + deltaY: 25, + }); + + expect(keyResponse['handled']).toBeTrue(); + expect(keyResponse['action']).toBe('onReturn'); + expect(onReturn).toHaveBeenCalledWith({ + text: 'send this', + selectionStart: 9, + selectionEnd: 9, + }); + expect(input.getAttribute('focused')).toBeFalse(); + expect(scrollResponse).toEqual( + jasmine.objectContaining({ + handled: true, + actionElementId: 6, + contentOffsetX: 5, + contentOffsetY: 35, + }), + ); + expect(scroll.getAttribute('contentOffsetX')).toBe(5); + expect(scroll.getAttribute('contentOffsetY')).toBe(35); + }); + + it('uses platform text input defaults when return-key closing is not configured', async () => { + const fieldOnReturn = jasmine.createSpy('fieldOnReturn'); + const viewOnReturn = jasmine.createSpy('viewOnReturn'); + const field = new TestElement(22, 'textfield', { + accessibilityId: 'default-field', + focused: true, + value: 'field', + onReturn: fieldOnReturn, + }); + const view = new TestElement(23, 'textview', { + accessibilityId: 'default-view', + focused: true, + value: 'view', + onReturn: viewOnReturn, + }); + const handler = makeHandler(makeNode(undefined, [makeNode(field, []), makeNode(view, [])])); + + const fieldResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'default-field', + key: 'Enter', + }); + const viewResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'default-view', + key: 'Enter', + }); + + expect(fieldResponse['handled']).toBeTrue(); + expect(viewResponse['handled']).toBeTrue(); + expect(field.getAttribute('focused')).toBeFalse(); + expect(view.getAttribute('focused')).toBeTrue(); + expect(view.getAttribute('value')).toBe('view\n'); + expect(fieldOnReturn).toHaveBeenCalledTimes(1); + expect(viewOnReturn).toHaveBeenCalledTimes(1); + expect(viewOnReturn).toHaveBeenCalledWith({ text: 'view\n', selectionStart: 5, selectionEnd: 5 }); + }); + + it('matches multiline Return insertion, ignore-newline, close, and callback ordering', async () => { + const callbackOrder: string[] = []; + const multiline = new TestElement(72, 'textview', { + accessibilityId: 'multiline-return', + value: 'ab', + selection: [1, 1], + focused: true, + closesWhenReturnKeyPressed: true, + onWillChange: (event: { text: string }) => { + callbackOrder.push(`will:${event.text}`); + }, + onChange: (event: { text: string }) => { + callbackOrder.push(`change:${event.text}`); + }, + onReturn: (event: { text: string }) => { + callbackOrder.push(`return:${event.text}:${String(multiline.getAttribute('focused'))}`); + }, + }); + const ignoreNewlines = new TestElement(73, 'textview', { + accessibilityId: 'ignore-return', + value: 'unchanged', + selection: [9, 9], + focused: true, + ignoreNewlines: true, + closesWhenReturnKeyPressed: false, + }); + const handler = makeHandler(makeNode(undefined, [makeNode(multiline, []), makeNode(ignoreNewlines, [])])); + + const multilineResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + elementId: 72, + key: 'Enter', + }); + const ignoredResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + elementId: 73, + key: 'Return', + }); + + expect(multilineResponse).toEqual( + jasmine.objectContaining({ handled: true, value: 'a\nb', selectionStart: 2, selectionEnd: 2 }), + ); + expect(multiline.getAttribute('focused')).toBeFalse(); + expect(callbackOrder).toEqual(['will:a\nb', 'change:a\nb', 'return:a\nb:false']); + expect(ignoredResponse).toEqual( + jasmine.objectContaining({ + handled: true, + action: 'return', + value: 'unchanged', + selectionStart: 9, + selectionEnd: 9, + }), + ); + expect(ignoreNewlines.getAttribute('value')).toBe('unchanged'); + expect(ignoreNewlines.getAttribute('focused')).toBeTrue(); + }); + + it('distinguishes backward and forward deletion at a collapsed caret', async () => { + const onWillDelete = jasmine.createSpy('onWillDelete'); + const backspaceInput = new TestElement(10, 'textfield', { + accessibilityId: 'backspace-input', + value: 'abcd', + selection: [2, 2], + onWillDelete, + }); + const deleteInput = new TestElement(11, 'textfield', { + accessibilityId: 'delete-input', + value: 'abcd', + selection: [2, 2], + onWillDelete, + }); + const handler = makeHandler(makeNode(undefined, [makeNode(backspaceInput, []), makeNode(deleteInput, [])])); + + const backspaceResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'backspace-input', + key: 'Backspace', + }); + const deleteResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'delete-input', + key: 'Delete', + }); + + expect(backspaceResponse).toEqual( + jasmine.objectContaining({ + handled: true, + value: 'acd', + selectionStart: 1, + selectionEnd: 1, + }), + ); + expect(deleteResponse).toEqual( + jasmine.objectContaining({ + handled: true, + value: 'abd', + selectionStart: 2, + selectionEnd: 2, + }), + ); + expect(onWillDelete).toHaveBeenCalledTimes(2); + }); + + it('inserts and deletes Unicode code points without splitting surrogate pairs', async () => { + const insertInput = new TestElement(24, 'textfield', { + accessibilityId: 'unicode-insert', + value: 'ab', + selection: [1, 1], + }); + const backspaceInput = new TestElement(25, 'textfield', { + accessibilityId: 'unicode-backspace', + value: 'a😀b', + selection: [3, 3], + }); + const deleteInput = new TestElement(26, 'textfield', { + accessibilityId: 'unicode-delete', + value: 'a😀b', + selection: [1, 1], + }); + const splitSelectionInput = new TestElement(27, 'textfield', { + accessibilityId: 'unicode-split-selection', + value: 'a😀b', + selection: [2, 3], + }); + const handler = makeHandler( + makeNode(undefined, [ + makeNode(insertInput, []), + makeNode(backspaceInput, []), + makeNode(deleteInput, []), + makeNode(splitSelectionInput, []), + ]), + ); + + const insertResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'unicode-insert', + key: '😀', + }); + const backspaceResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'unicode-backspace', + key: 'Backspace', + }); + const deleteResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'unicode-delete', + key: 'Delete', + }); + const splitSelectionResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'unicode-split-selection', + key: 'Backspace', + }); + + expect(insertResponse).toEqual( + jasmine.objectContaining({ handled: true, value: 'a😀b', selectionStart: 3, selectionEnd: 3 }), + ); + expect(backspaceResponse).toEqual( + jasmine.objectContaining({ handled: true, value: 'ab', selectionStart: 1, selectionEnd: 1 }), + ); + expect(deleteResponse).toEqual( + jasmine.objectContaining({ handled: true, value: 'ab', selectionStart: 1, selectionEnd: 1 }), + ); + expect(splitSelectionResponse).toEqual( + jasmine.objectContaining({ handled: true, value: 'ab', selectionStart: 1, selectionEnd: 1 }), + ); + for (const input of [insertInput, backspaceInput, deleteInput, splitSelectionInput]) { + expect(containsLoneSurrogate(input.getAttribute('value'))).toBeFalse(); + } + }); + + it('rejects lone-surrogate key payloads without mutating text', async () => { + const input = new TestElement(28, 'textfield', { + accessibilityId: 'invalid-unicode-key', + value: 'safe', + selection: [4, 4], + }); + const handler = makeHandler(makeNode(undefined, [makeNode(input, [])])); + + const response = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'invalid-unicode-key', + key: String.fromCharCode(0xd83d), + }); + + expect(response['handled']).toBeFalse(); + expect(input.getAttribute('value')).toBe('safe'); + expect(input.getAttribute('selection')).toEqual([4, 4]); + expect(containsLoneSurrogate(input.getAttribute('value'))).toBeFalse(); + }); + + it('inserts and deletes whole grapheme clusters', async () => { + const input = new TestElement(74, 'textfield', { + accessibilityId: 'grapheme-input', + value: '', + selection: [0, 0], + }); + const handler = makeHandler(makeNode(undefined, [makeNode(input, [])])); + const graphemes = ['e\u0301', '👍🏽', '🇺🇸', '✈️', '👨‍👩‍👧‍👦']; + + for (const grapheme of graphemes) { + input.setAttributes({ value: `a${grapheme}b`, selection: [1 + grapheme.length, 1 + grapheme.length] }); + const backspaceResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + elementId: 74, + key: 'Backspace', + }); + expect(backspaceResponse).toEqual( + jasmine.objectContaining({ handled: true, value: 'ab', selectionStart: 1, selectionEnd: 1 }), + ); + + input.setAttributes({ value: `a${grapheme}b`, selection: [1, 1] }); + const deleteResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + elementId: 74, + key: 'Delete', + }); + expect(deleteResponse).toEqual( + jasmine.objectContaining({ handled: true, value: 'ab', selectionStart: 1, selectionEnd: 1 }), + ); + + input.setAttributes({ value: 'ab', selection: [1, 1] }); + const insertResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + elementId: 74, + key: grapheme, + }); + expect(insertResponse).toEqual( + jasmine.objectContaining({ + handled: true, + value: `a${grapheme}b`, + selectionStart: 1 + grapheme.length, + selectionEnd: 1 + grapheme.length, + }), + ); + expect(containsLoneSurrogate(input.getAttribute('value'))).toBeFalse(); + } + }); + + it('does not split a grapheme cluster after a large text prefix', async () => { + const globalObject = globalThis as { Intl?: typeof Intl }; + const savedIntl = globalObject.Intl; + const prefix = 'a'.repeat(1000001); + const grapheme = '👨‍👩‍👧‍👦'; + const input = new TestElement(82, 'textfield', { + accessibilityId: 'large-grapheme-input', + value: `${prefix}${grapheme}b`, + selection: [prefix.length + grapheme.length, prefix.length + grapheme.length], + }); + const handler = makeHandler(makeNode(undefined, [makeNode(input, [])])); + + try { + globalObject.Intl = undefined; + const response = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + elementId: 82, + key: 'Backspace', + }); + + expect(response).toEqual( + jasmine.objectContaining({ + handled: true, + value: `${prefix}b`, + selectionStart: prefix.length, + selectionEnd: prefix.length, + }), + ); + expect(containsLoneSurrogate(input.getAttribute('value'))).toBeFalse(); + } finally { + globalObject.Intl = savedIntl; + } + }); + + it('rejects lone-surrogate full text and existing values before mutation', async () => { + const input = new TestElement(75, 'textfield', { + accessibilityId: 'unicode-validation', + value: 'safe', + selection: [4, 4], + }); + const handler = makeHandler(makeNode(undefined, [makeNode(input, [])])); + const invalidUnicode = String.fromCharCode(0xd83d); + + for (const request of [ + { type: DebuggerInputType.Text, elementId: 75, text: invalidUnicode }, + { type: DebuggerInputType.Text, elementId: 75, value: invalidUnicode }, + ]) { + const response = await send(handler, { ...request, contextId: 'context-1' }); + expect(response['handled']).toBeFalse(); + expect(response['message']).toContain('must contain valid Unicode'); + expect(input.getAttribute('value')).toBe('safe'); + } + + input.setAttributes({ value: invalidUnicode, selection: [1, 1] }); + const response = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + elementId: 75, + key: 'x', + }); + expect(response['handled']).toBeFalse(); + expect(response['message']).toBe('Element 75 contains invalid Unicode text.'); + expect(input.getAttribute('value')).toBe(invalidUnicode); + }); + + it('rejects disabled focus, text, and key actions without mutating the input', async () => { + const onChange = jasmine.createSpy('onChange'); + const disabledByEnabled = new TestElement(12, 'textfield', { + accessibilityId: 'disabled-by-enabled', + enabled: false, + focused: false, + value: 'original', + selection: [8, 8], + onChange, + }); + const disabledByAccessibility = new TestElement(13, 'textfield', { + accessibilityId: 'disabled-by-accessibility', + accessibilityStateDisabled: true, + focused: false, + value: 'original', + selection: [8, 8], + onChange, + }); + const handler = makeHandler( + makeNode(undefined, [makeNode(disabledByEnabled, []), makeNode(disabledByAccessibility, [])]), + ); + + const focusResponse = await send(handler, { + type: DebuggerInputType.Focus, + contextId: 'context-1', + accessibilityId: 'disabled-by-enabled', + focused: true, + }); + const textResponse = await send(handler, { + type: DebuggerInputType.Text, + contextId: 'context-1', + accessibilityId: 'disabled-by-accessibility', + text: 'changed', + }); + const keyResponse = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'disabled-by-accessibility', + key: 'x', + }); + + expect(focusResponse).toEqual( + jasmine.objectContaining({ handled: false, actionElementId: 12, message: 'Element 12 is disabled.' }), + ); + expect(textResponse).toEqual( + jasmine.objectContaining({ handled: false, actionElementId: 13, message: 'Element 13 is disabled.' }), + ); + expect(keyResponse).toEqual( + jasmine.objectContaining({ handled: false, actionElementId: 13, message: 'Element 13 is disabled.' }), + ); + expect(disabledByEnabled.getAttribute('focused')).toBeFalse(); + expect(disabledByAccessibility.getAttribute('focused')).toBeFalse(); + expect(disabledByAccessibility.getAttribute('value')).toBe('original'); + expect(disabledByAccessibility.getAttribute('selection')).toEqual([8, 8]); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('applies ancestor interaction gates to focus, text, key, and scroll and rejects read-only edits', async () => { + const touchDisabledAncestor = new TestElement(76, 'view', { touchEnabled: false }); + const gatedInput = new TestElement(77, 'textfield', { + accessibilityId: 'gated-input', + value: 'safe', + selection: [4, 4], + focused: false, + }); + gatedInput.parent = touchDisabledAncestor.asRenderedElement(); + touchDisabledAncestor.children.push(gatedInput.asRenderedElement()); + + const disabledAncestor = new TestElement(78, 'view', { enabled: false }); + const gatedScroll = new TestElement(79, 'scroll', { + accessibilityId: 'gated-scroll', + contentOffsetX: 1, + contentOffsetY: 2, + }); + gatedScroll.parent = disabledAncestor.asRenderedElement(); + disabledAncestor.children.push(gatedScroll.asRenderedElement()); + + const readOnlyInput = new TestElement(80, 'textfield', { + accessibilityId: 'read-only-input', + editable: false, + value: 'read only', + selection: [9, 9], + }); + const handler = makeHandler( + makeNode(undefined, [ + makeNode(touchDisabledAncestor, [makeNode(gatedInput, [])]), + makeNode(disabledAncestor, [makeNode(gatedScroll, [])]), + makeNode(readOnlyInput, []), + ]), + ); + + for (const request of [ + { type: DebuggerInputType.Focus, elementId: 77, focused: true }, + { type: DebuggerInputType.Text, elementId: 77, text: 'changed' }, + { type: DebuggerInputType.Key, elementId: 77, key: 'x' }, + ]) { + const response = await send(handler, { ...request, contextId: 'context-1' }); + expect(response['handled']).toBeFalse(); + expect(response['message']).toBe('Element 76 has touchEnabled=false.'); + } + const scrollResponse = await send(handler, { + type: DebuggerInputType.Scroll, + contextId: 'context-1', + elementId: 79, + deltaY: 20, + }); + expect(scrollResponse['handled']).toBeFalse(); + expect(scrollResponse['message']).toBe('Element 78 is disabled.'); + + for (const request of [ + { type: DebuggerInputType.Text, elementId: 80, text: 'changed' }, + { type: DebuggerInputType.Key, elementId: 80, key: 'x' }, + ]) { + const response = await send(handler, { ...request, contextId: 'context-1' }); + expect(response['handled']).toBeFalse(); + expect(response['message']).toBe('Element 80 is not editable.'); + } + + expect(gatedInput.getAttribute('focused')).toBeFalse(); + expect(gatedInput.getAttribute('value')).toBe('safe'); + expect(gatedScroll.getAttribute('contentOffsetY')).toBe(2); + expect(readOnlyInput.getAttribute('value')).toBe('read only'); + }); + + it('rejects handler promises when an input callback throws', async () => { + const button = new TestElement(81, 'view', { + accessibilityId: 'throwing-button', + onTap: () => { + throw new Error('tap callback failed'); + }, + }); + const handler = makeHandler(makeNode(undefined, [makeNode(button, [])])); + + await expectAsync( + send(handler, { + type: DebuggerInputType.Tap, + contextId: 'context-1', + elementId: 81, + }), + ).toBeRejectedWithError('tap callback failed'); + }); + + it('rejects missing contexts and callbacks with specific errors', async () => { + const plainView = new TestElement(14, 'view', { accessibilityId: 'plain-view' }); + const inputWithoutReturn = new TestElement(15, 'textfield', { + accessibilityId: 'input-without-return', + focused: true, + value: 'draft', + }); + const handler = makeHandler(makeNode(undefined, [makeNode(plainView, []), makeNode(inputWithoutReturn, [])])); + + const missingContext = await send(handler, { + type: DebuggerInputType.Query, + }); + const unknownContext = await send(handler, { + type: DebuggerInputType.Query, + contextId: 'missing-context', + }); + const missingTap = await send(handler, { + type: DebuggerInputType.Tap, + contextId: 'context-1', + accessibilityId: 'plain-view', + }); + const returnWithoutCallback = await send(handler, { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'input-without-return', + key: 'Enter', + }); + + expect(missingContext['handled']).toBeFalse(); + expect(missingContext['message']).toBe('A contextId is required.'); + expect(unknownContext['handled']).toBeFalse(); + expect(unknownContext['message']).toBe('No Valdi renderer found for context missing-context.'); + expect(missingTap['handled']).toBeFalse(); + expect(missingTap['message']).toBe('Element 14 and its ancestors do not expose onTap.'); + expect(returnWithoutCallback['handled']).toBeTrue(); + expect(returnWithoutCallback['action']).toBe('focused'); + expect(inputWithoutReturn.getAttribute('focused')).toBeFalse(); + }); + + it('rejects unsupported operations and non-object requests as handled failures', async () => { + const handler = makeHandler(makeNode(undefined, [])); + + const unsupported = await send(handler, { + type: 'drag', + contextId: 'context-1', + }); + const nonObjectPromise = handler.messageReceived(DEBUGGER_INPUT_IDENTIFIER, []); + if (!nonObjectPromise) { + throw new Error('Expected debugger input handler to accept the request.'); + } + const nonObject = await nonObjectPromise; + + expect(unsupported['handled']).toBeFalse(); + expect(unsupported['message']).toBe("Unsupported debugger input type 'drag'."); + expect(nonObject['handled']).toBeFalse(); + expect(nonObject['message']).toBe('Debugger input request must be an object.'); + }); + + it('validates selectors and action payloads before mutation or callback dispatch', async () => { + const onTap = jasmine.createSpy('onTap'); + const onChange = jasmine.createSpy('onChange'); + const button = new TestElement(16, 'view', { accessibilityId: 'button', onTap }); + const input = new TestElement(17, 'textfield', { + accessibilityId: 'input', + focused: false, + value: 'original', + selection: [8, 8], + onChange, + }); + const scroll = new TestElement(18, 'scroll', { + accessibilityId: 'scroll', + contentOffsetX: 1, + contentOffsetY: 2, + }); + const handler = makeHandler(makeNode(undefined, [makeNode(button, []), makeNode(input, []), makeNode(scroll, [])])); + + const malformedRequests: Array<{ request: { [name: string]: any }; message: string }> = [ + { + request: { type: DebuggerInputType.Tap, contextId: 'context-1', elementId: '16' }, + message: 'elementId must be a finite integer.', + }, + { + request: { type: DebuggerInputType.Tap, contextId: 'context-1', elementId: Number.NaN }, + message: 'elementId must be a finite integer.', + }, + { + request: { + type: DebuggerInputType.Query, + contextId: 'context-1', + selector: { accessibilityId: 'input', unknown: true }, + }, + message: "Unsupported selector field 'unknown'.", + }, + { + request: { + type: DebuggerInputType.Query, + contextId: 'context-1', + selector: { zUnknown: true, aUnknown: true }, + }, + message: "Unsupported selector field 'aUnknown'.", + }, + { + request: { type: DebuggerInputType.Query, contextId: 'context-1', selector: [] }, + message: 'selector must be a string or an object.', + }, + { + request: { type: DebuggerInputType.Query, contextId: 'context-1', selector: { elementId: undefined } }, + message: 'selector object must include elementId, accessibilityId, or tag.', + }, + { + request: { type: DebuggerInputType.Query, contextId: 'context-1', accessibilityId: '' }, + message: 'accessibilityId must not be empty.', + }, + { + request: { + type: DebuggerInputType.Query, + contextId: 'context-1', + elementId: 17, + accessibilityId: 'input', + }, + message: 'Use only one of elementId, accessibilityId, or selector.', + }, + { + request: { + type: DebuggerInputType.Query, + contextId: 'context-1', + selector: { elementId: 17.5 }, + }, + message: 'selector.elementId must be a finite integer.', + }, + { + request: { type: DebuggerInputType.Tap, contextId: 'context-1', accessibilityId: 'button', x: Infinity }, + message: 'x must be a finite number.', + }, + { + request: { + type: DebuggerInputType.Focus, + contextId: 'context-1', + accessibilityId: 'input', + focused: 'yes', + }, + message: 'focused must be a boolean.', + }, + { + request: { type: DebuggerInputType.Text, contextId: 'context-1', accessibilityId: 'input', text: 7 }, + message: 'text must be a string.', + }, + { + request: { type: DebuggerInputType.Text, contextId: 'context-1', accessibilityId: 'input' }, + message: 'Text input requires a string text or value.', + }, + { + request: { + type: DebuggerInputType.Text, + contextId: 'context-1', + accessibilityId: 'input', + text: 'first', + value: 'second', + }, + message: 'Use only one of text or value.', + }, + { + request: { type: DebuggerInputType.Tap, contextId: 'context-1', accessibilityId: 'button', text: 'ignored' }, + message: "Field 'text' is not supported for tap input.", + }, + { + request: { type: DebuggerInputType.Key, contextId: 'context-1', accessibilityId: 'input', key: 7 }, + message: 'key must be a string.', + }, + { + request: { type: DebuggerInputType.Key, contextId: 'context-1', accessibilityId: 'input', key: '' }, + message: 'key must not be empty.', + }, + { + request: { + type: DebuggerInputType.Key, + contextId: 'context-1', + accessibilityId: 'input', + key: 'x', + selectionStart: Number.NaN, + }, + message: 'selectionStart must be a finite integer.', + }, + { + request: { + type: DebuggerInputType.Scroll, + contextId: 'context-1', + accessibilityId: 'scroll', + deltaY: Infinity, + }, + message: 'deltaY must be a finite number.', + }, + ]; + + for (const malformed of malformedRequests) { + const response = await send(handler, malformed.request); + expect(response['handled']).toBeFalse(); + expect(response['message']).toBe(malformed.message); + } + expect(onTap).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(input.getAttribute('focused')).toBeFalse(); + expect(input.getAttribute('value')).toBe('original'); + expect(input.getAttribute('selection')).toEqual([8, 8]); + expect(scroll.getAttribute('contentOffsetX')).toBe(1); + expect(scroll.getAttribute('contentOffsetY')).toBe(2); + }); + + it('rejects ambiguous accessibility identifiers', async () => { + const first = new TestElement(8, 'view', { accessibilityId: 'duplicate' }); + const second = new TestElement(9, 'view', { accessibilityId: 'duplicate' }); + const handler = makeHandler(makeNode(undefined, [makeNode(first, []), makeNode(second, [])])); + + const response = await send(handler, { + type: DebuggerInputType.Tap, + contextId: 'context-1', + accessibilityId: 'duplicate', + }); + + expect(response['handled']).toBeFalse(); + expect(response['message']).toContain('matched 2 elements'); + expect(response['elements'].length).toBe(2); + }); +}); diff --git a/valdi/src/valdi/macos/SCValdiMacOSAttributesBinder.h b/valdi/src/valdi/macos/SCValdiMacOSAttributesBinder.h index 5c75d7c29..30ca7a939 100644 --- a/valdi/src/valdi/macos/SCValdiMacOSAttributesBinder.h +++ b/valdi/src/valdi/macos/SCValdiMacOSAttributesBinder.h @@ -19,6 +19,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)bindColorAttribute:(NSString*)attributeName invalidateLayoutOnChange:(BOOL)invalidateLayoutOnChange selector:(SEL)sel; +- (void)bindAccessibilityAttributes; @end diff --git a/valdi/src/valdi/macos/SCValdiMacOSAttributesBinder.mm b/valdi/src/valdi/macos/SCValdiMacOSAttributesBinder.mm index f2c026ea2..e08430695 100644 --- a/valdi/src/valdi/macos/SCValdiMacOSAttributesBinder.mm +++ b/valdi/src/valdi/macos/SCValdiMacOSAttributesBinder.mm @@ -14,10 +14,117 @@ #import "valdi/macos/SCValdiMacOSViewManager.h" #import "valdi/macos/Views/SCValdiSurfacePresenterView.h" +#import #import typedef void (*SCValdiObjectSetter)(id, SEL, id); +static const void *SCValdiOriginalAccessibilityRoleKey = &SCValdiOriginalAccessibilityRoleKey; +static const void *SCValdiOriginalAccessibilityElementKey = &SCValdiOriginalAccessibilityElementKey; +static const void *SCValdiOriginalAccessibilityEnabledKey = &SCValdiOriginalAccessibilityEnabledKey; + +@interface NSView (SCValdiAccessibility) +- (void)valdi_setAccessibilityCategory:(nullable NSString *)category; +- (void)valdi_setAccessibilityStateDisabled:(nullable NSNumber *)disabled; +- (void)valdi_setAccessibilityStateSelected:(nullable NSNumber *)selected; +@end + +@implementation NSView (SCValdiAccessibility) + +- (void)valdi_storeOriginalAccessibilityPropertiesIfNeeded +{ + if (objc_getAssociatedObject(self, SCValdiOriginalAccessibilityRoleKey) != nil) { + return; + } + objc_setAssociatedObject( + self, + SCValdiOriginalAccessibilityRoleKey, + self.accessibilityRole ?: NSNull.null, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject( + self, + SCValdiOriginalAccessibilityElementKey, + @(self.isAccessibilityElement), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (void)valdi_restoreOriginalAccessibilityProperties +{ + id originalRole = objc_getAssociatedObject(self, SCValdiOriginalAccessibilityRoleKey); + NSNumber *originalElement = objc_getAssociatedObject(self, SCValdiOriginalAccessibilityElementKey); + self.accessibilityRole = originalRole == NSNull.null ? nil : originalRole; + if (originalElement != nil) { + self.accessibilityElement = originalElement.boolValue; + } +} + +- (void)valdi_setAccessibilityCategory:(NSString *)category +{ + [self valdi_storeOriginalAccessibilityPropertiesIfNeeded]; + if (category == nil || [category isEqualToString:@"auto"]) { + [self valdi_restoreOriginalAccessibilityProperties]; + return; + } + + NSAccessibilityRole role = NSAccessibilityGroupRole; + if ([category isEqualToString:@"text"] || [category isEqualToString:@"header"]) { + role = NSAccessibilityStaticTextRole; + } else if ([category isEqualToString:@"button"] || + [category isEqualToString:@"image-button"] || + [category isEqualToString:@"keyboard-key"]) { + role = NSAccessibilityButtonRole; + } else if ([category isEqualToString:@"image"]) { + role = NSAccessibilityImageRole; + } else if ([category isEqualToString:@"input"]) { + role = NSAccessibilityTextFieldRole; + } else if ([category isEqualToString:@"link"]) { + role = NSAccessibilityLinkRole; + } else if ([category isEqualToString:@"checkbox"]) { + role = NSAccessibilityCheckBoxRole; + } else if ([category isEqualToString:@"radio"]) { + role = NSAccessibilityRadioButtonRole; + } + + self.accessibilityElement = YES; + self.accessibilityRole = role; +} + +- (void)valdi_setAccessibilityStateDisabled:(NSNumber *)disabled +{ + NSNumber *originalEnabled = objc_getAssociatedObject(self, SCValdiOriginalAccessibilityEnabledKey); + if (disabled == nil) { + if ([self isKindOfClass:NSControl.class]) { + self.accessibilityEnabled = ((NSControl *)self).isEnabled; + } else if (originalEnabled != nil) { + self.accessibilityEnabled = originalEnabled.boolValue; + } + objc_setAssociatedObject(self, SCValdiOriginalAccessibilityEnabledKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return; + } + + if (originalEnabled == nil) { + originalEnabled = @(self.isAccessibilityEnabled); + objc_setAssociatedObject( + self, + SCValdiOriginalAccessibilityEnabledKey, + originalEnabled, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + + BOOL nativeEnabled = originalEnabled.boolValue; + if ([self isKindOfClass:NSControl.class]) { + nativeEnabled = ((NSControl *)self).isEnabled; + } + self.accessibilityEnabled = !disabled.boolValue && nativeEnabled; +} + +- (void)valdi_setAccessibilityStateSelected:(NSNumber *)selected +{ + self.accessibilitySelected = selected != nil && selected.boolValue; +} + +@end + class AttributeHandler: public Valdi::AttributeHandlerDelegate { public: AttributeHandler(SEL sel): _sel(sel) {} @@ -83,4 +190,29 @@ - (void)bindColorAttribute:(NSString *)attributeName invalidateLayoutOnChange:(B _cppInstance->bindColorAttribute(cppAttributeName, invalidateLayoutOnChange, Valdi::makeShared(sel)); } +- (void)bindAccessibilityAttributes +{ + [self bindUntypedAttribute:@"accessibilityId" + invalidateLayoutOnChange:NO + selector:@selector(setAccessibilityIdentifier:)]; + [self bindUntypedAttribute:@"accessibilityLabel" + invalidateLayoutOnChange:NO + selector:@selector(setAccessibilityLabel:)]; + [self bindUntypedAttribute:@"accessibilityHint" + invalidateLayoutOnChange:NO + selector:@selector(setAccessibilityHelp:)]; + [self bindUntypedAttribute:@"accessibilityValue" + invalidateLayoutOnChange:NO + selector:@selector(setAccessibilityValue:)]; + [self bindUntypedAttribute:@"accessibilityCategory" + invalidateLayoutOnChange:NO + selector:@selector(valdi_setAccessibilityCategory:)]; + [self bindUntypedAttribute:@"accessibilityStateDisabled" + invalidateLayoutOnChange:NO + selector:@selector(valdi_setAccessibilityStateDisabled:)]; + [self bindUntypedAttribute:@"accessibilityStateSelected" + invalidateLayoutOnChange:NO + selector:@selector(valdi_setAccessibilityStateSelected:)]; +} + @end diff --git a/valdi/src/valdi/macos/SCValdiMacOSFunction.h b/valdi/src/valdi/macos/SCValdiMacOSFunction.h index 3861e4149..8ab35b234 100644 --- a/valdi/src/valdi/macos/SCValdiMacOSFunction.h +++ b/valdi/src/valdi/macos/SCValdiMacOSFunction.h @@ -7,7 +7,9 @@ #import -typedef id (^SCValdiMacOSFunctionBlock)(NSArray* parameters); +NS_ASSUME_NONNULL_BEGIN + +typedef id _Nullable (^SCValdiMacOSFunctionBlock)(NSArray* parameters); @interface SCValdiMacOSFunction : NSObject @@ -17,5 +19,8 @@ typedef id (^SCValdiMacOSFunctionBlock)(NSArray* parameters); - (instancetype)initWithBlock:(SCValdiMacOSFunctionBlock)block; - (void)performWithParameters:(NSArray*)parameters; +- (nullable id)performWithParametersAndReturnValue:(NSArray*)parameters; @end + +NS_ASSUME_NONNULL_END diff --git a/valdi/src/valdi/macos/SCValdiMacOSFunction.mm b/valdi/src/valdi/macos/SCValdiMacOSFunction.mm index f779f64c5..3c7e70744 100644 --- a/valdi/src/valdi/macos/SCValdiMacOSFunction.mm +++ b/valdi/src/valdi/macos/SCValdiMacOSFunction.mm @@ -60,6 +60,21 @@ - (void)performWithParameters:(NSArray *)parameters (*_cppInstance)(outParameters.data(), outParameters.size()); } +- (id)performWithParametersAndReturnValue:(NSArray *)parameters +{ + Valdi::SmallVector outParameters; + for (id parameter in parameters) { + outParameters.emplace_back(ValueFromNSObject(parameter)); + } + + auto result = _cppInstance->call( + Valdi::ValueFunctionFlagsCallSync, outParameters.data(), outParameters.size()); + if (!result) { + return nil; + } + return NSObjectFromValue(result.value()); +} + - (void *)cppInstance { return _cppInstance.get(); diff --git a/valdi/src/valdi/macos/SCValdiMacOSViewManager.mm b/valdi/src/valdi/macos/SCValdiMacOSViewManager.mm index bf45a2fd9..d43ae7252 100644 --- a/valdi/src/valdi/macos/SCValdiMacOSViewManager.mm +++ b/valdi/src/valdi/macos/SCValdiMacOSViewManager.mm @@ -11,6 +11,8 @@ #import "valdi/macos/Views/SCValdiSurfacePresenterView.h" #import "valdi_core/cpp/Utils/StringCache.hpp" #import "valdi/runtime/Context/ViewNodeTree.hpp" +#import "valdi/runtime/Context/ViewNode.hpp" +#import "valdi/runtime/Runtime.hpp" #import "valdi/runtime/Views/PlaceholderViewMeasureDelegate.hpp" #import "valdi_core/cpp/Interfaces/IBitmap.hpp" #import "valdi/runtime/Attributes/BoundAttributes.hpp" @@ -22,16 +24,100 @@ #import "valdi/runtime/Views/DeferredViewTransaction.hpp" #import "valdi_core/cpp/Utils/TrackedLock.hpp" #import "snap_drawing/cpp/Utils/BitmapFactory.hpp" +#import #include +static const void *SCValdiMacOSViewNodeKey = &SCValdiMacOSViewNodeKey; + +@interface SCValdiMacOSViewNodeHandle : NSObject { +@public + Valdi::Weak _viewNode; +} + +- (instancetype)initWithViewNode:(Valdi::ViewNode *)viewNode; + +@end + +@implementation SCValdiMacOSViewNodeHandle + +- (instancetype)initWithViewNode:(Valdi::ViewNode *)viewNode +{ + self = [super init]; + if (self) { + _viewNode = Valdi::weakRef(viewNode); + } + return self; +} + +@end + +static Valdi::Ref SCValdiMacOSGetAttachedViewNode(NSView *view) +{ + SCValdiMacOSViewNodeHandle *handle = objc_getAssociatedObject(view, SCValdiMacOSViewNodeKey); + return handle == nil ? nullptr : Valdi::Ref(handle->_viewNode.lock()); +} + +@interface NSView (SCValdiMacOSNativeAttributeState) +- (void)valdi_setAttachedViewNode:(Valdi::ViewNode *)viewNode; +- (BOOL)valdi_hasAttachedViewNode; +- (BOOL)valdi_hasAttachedViewNodeHandle; +- (BOOL)valdi_isAttachedToViewNode:(Valdi::ViewNode *)viewNode; +- (void)valdi_didChangeValue:(id)value forAttribute:(NSString *)attributeName; +@end + +@implementation NSView (SCValdiMacOSNativeAttributeState) + +- (void)valdi_setAttachedViewNode:(Valdi::ViewNode *)viewNode +{ + SCValdiMacOSViewNodeHandle *handle = + viewNode == nullptr ? nil : [[SCValdiMacOSViewNodeHandle alloc] initWithViewNode:viewNode]; + objc_setAssociatedObject(self, SCValdiMacOSViewNodeKey, handle, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (BOOL)valdi_hasAttachedViewNode +{ + return SCValdiMacOSGetAttachedViewNode(self) != nullptr; +} + +- (BOOL)valdi_hasAttachedViewNodeHandle +{ + return objc_getAssociatedObject(self, SCValdiMacOSViewNodeKey) != nil; +} + +- (BOOL)valdi_isAttachedToViewNode:(Valdi::ViewNode *)viewNode +{ + auto attachedViewNode = SCValdiMacOSGetAttachedViewNode(self); + return attachedViewNode.get() == viewNode; +} + +- (void)valdi_didChangeValue:(id)value forAttribute:(NSString *)attributeName +{ + auto viewNode = SCValdiMacOSGetAttachedViewNode(self); + auto *viewNodeTree = viewNode != nullptr ? viewNode->getViewNodeTree() : nullptr; + auto runtime = viewNodeTree != nullptr ? viewNodeTree->getRuntime() : nullptr; + if (runtime == nullptr) { + return; + } + Valdi::Value attributeValue = [attributeName isEqualToString:@"focused"] + ? Valdi::Value([value boolValue]) + : ValueFromNSObject(value); + runtime->updateAttributeState(*viewNode, StringFromNSString(attributeName), attributeValue); +} + +@end + namespace ValdiMacOS { class NSViewWrapper: public Valdi::View { public: NSViewWrapper(NSView *view): _view(view) {} - ~NSViewWrapper() override = default; + ~NSViewWrapper() override { + // A host may retain a detached root NSView after its ViewNode and wrapper have been destroyed. + // Drop the weak handle promptly so a future wrapper cannot observe the previous attachment. + [_view valdi_setAttachedViewNode:nullptr]; + } NSView *getView() const { return _view; @@ -185,7 +271,9 @@ void didUpdateRootView(const Valdi::Ref& view, bool layoutDidBecome void moveViewToTree(const Valdi::Ref& view, Valdi::ViewNodeTree* viewNodeTree, - Valdi::ViewNode* viewNode) override {} + Valdi::ViewNode* viewNode) override { + [fromValdiView(view) valdi_setAttachedViewNode:viewNode]; + } void insertChildView(const Valdi::Ref& view, const Valdi::Ref& childView, @@ -194,7 +282,11 @@ void insertChildView(const Valdi::Ref& view, void removeViewFromParent(const Valdi::Ref& view, const Valdi::Ref& animator, - bool shouldClearViewNode) override {} + bool shouldClearViewNode) override { + if (shouldClearViewNode) { + [fromValdiView(view) valdi_setAttachedViewNode:nullptr]; + } + } void invalidateViewLayout(const Valdi::Ref& view) override {} @@ -217,7 +309,10 @@ void layoutView(const Valdi::Ref& view) override {} void cancelAllViewAnimations(const Valdi::Ref& view) override {} void willEnqueueViewToPool(const Valdi::Ref& view, - Valdi::Function onEnqueue) override {} + Valdi::Function onEnqueue) override { + [fromValdiView(view) valdi_setAttachedViewNode:nullptr]; + onEnqueue(*view); + } void snapshotView(const Valdi::Ref& view, Valdi::Function)> cb) override { @@ -412,6 +507,7 @@ Class lookupClass() { binder.setMeasureDelegate(Valdi::makeShared(cls)); SCValdiMacOSAttributesBinder *attributesBinder = [[SCValdiMacOSAttributesBinder alloc] initWithCppInstance:(void *)&binder cls:cls]; + [attributesBinder bindAccessibilityAttributes]; if ([cls respondsToSelector:@selector(bindAttributes:)]) { [cls bindAttributes:attributesBinder]; diff --git a/valdi/src/valdi/macos/SCValdiObjCUtils.mm b/valdi/src/valdi/macos/SCValdiObjCUtils.mm index da4e5537c..86ec1bae1 100644 --- a/valdi/src/valdi/macos/SCValdiObjCUtils.mm +++ b/valdi/src/valdi/macos/SCValdiObjCUtils.mm @@ -9,7 +9,9 @@ #import "valdi_core/SCValdiNativeConvertible.h" #import "valdi_core/cpp/Utils/StringCache.hpp" +#import "valdi_core/cpp/Utils/ValueArray.hpp" #import "valdi_core/cpp/Utils/ValueFunction.hpp" +#import "valdi_core/cpp/Utils/ValueMap.hpp" #import "djinni/objc/DJIMarshal+Private.h" #import "SCValdiMacOSFunction.h" @@ -76,8 +78,16 @@ id NSObjectFromValue(const Valdi::Value &value) { return @(value.toDouble()); case Valdi::ValueType::Bool: return @(value.toBool()); - case Valdi::ValueType::Map: - return nil; + case Valdi::ValueType::Map: { + const Valdi::ValueMap* map = value.getMap(); + if (!map) return nil; + NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:map->size()]; + for (const auto &entry : *map) { + id object = NSObjectFromValue(entry.second); + result[NSStringFromString(entry.first)] = object ?: [NSNull null]; + } + return result; + } case Valdi::ValueType::Array: { const Valdi::ValueArray* arr = value.getArray(); if (!arr) return nil; @@ -130,6 +140,15 @@ id NSObjectFromValdiObject(const Valdi::SharedValdiObject &valdiObject) { return Valdi::Value(map); } +Valdi::Value ValueFromNSArray(NSArray *array) { + auto valueArray = Valdi::ValueArray::make(array.count); + size_t index = 0; + for (id item in array) { + valueArray->emplace(index++, ValueFromNSObject(item)); + } + return Valdi::Value(valueArray); +} + Valdi::Value ValueFromNSObject(id object) { if (!object || [object isKindOfClass:[NSNull class]]) { return Valdi::Value::undefined(); @@ -143,6 +162,9 @@ id NSObjectFromValdiObject(const Valdi::SharedValdiObject &valdiObject) { if ([object isKindOfClass:[NSDictionary class]]) { return ValueFromNSDictionary(object); } + if ([object isKindOfClass:[NSArray class]]) { + return ValueFromNSArray(object); + } if ([object isKindOfClass:[SCValdiMacOSFunction class]]) { Valdi::ValueFunction *function = (Valdi::ValueFunction *)((SCValdiMacOSFunction *)object).cppInstance; return Valdi::Value(Valdi::Ref(function)); diff --git a/valdi/src/valdi/macos/Views/SCValdiMacOSTextField.m b/valdi/src/valdi/macos/Views/SCValdiMacOSTextField.m index e9e063cd7..b74461d0b 100644 --- a/valdi/src/valdi/macos/Views/SCValdiMacOSTextField.m +++ b/valdi/src/valdi/macos/Views/SCValdiMacOSTextField.m @@ -60,9 +60,19 @@ return [NSColor colorWithRed:r green:g blue:b alpha:a]; } +typedef NS_ENUM(NSInteger, SCValdiMacOSTextInputUnfocusReason) { + SCValdiMacOSTextInputUnfocusReasonUnknown = 0, + SCValdiMacOSTextInputUnfocusReasonReturnKeyPress = 1, + SCValdiMacOSTextInputUnfocusReasonDismissKeyPress = 2, +}; + @interface SCValdiMacOSTextField() @end +@interface NSView (SCValdiMacOSNativeAttributeState) +- (void)valdi_didChangeValue:(id)value forAttribute:(NSString *)attributeName; +@end + @implementation SCValdiMacOSTextField { NSColor *_placeholderColor; BOOL _placeholderDirty; @@ -71,6 +81,15 @@ @implementation SCValdiMacOSTextField { SCValdiMacOSFunction *_onWillChange; SCValdiMacOSFunction *_onEditBegin; SCValdiMacOSFunction *_onEditEnd; + SCValdiMacOSFunction *_onReturn; + SCValdiMacOSFunction *_onWillDelete; + NSRange _pendingSelection; + BOOL _hasPendingSelection; + BOOL _selectTextOnFocus; + BOOL _closesWhenReturnKeyPressed; + BOOL _shouldBecomeFocusedWhenAttached; + __weak NSText *_observedEditor; + SCValdiMacOSTextInputUnfocusReason _lastUnfocusReason; } - (instancetype)initWithFrame:(NSRect)frameRect @@ -81,6 +100,7 @@ - (instancetype)initWithFrame:(NSRect)frameRect self.delegate = self; self.bezeled = NO; self.drawsBackground = NO; + _closesWhenReturnKeyPressed = YES; } return self; @@ -123,34 +143,188 @@ - (void)_updatePlaceholderIfNeeded } } -- (void)_submitEventToFuntion:(SCValdiMacOSFunction *)func +- (NSDictionary *)_editTextEvent +{ + NSText *editor = self.currentEditor; + NSString *text = editor.string ?: self.stringValue ?: @""; + NSRange selection = editor != nil ? editor.selectedRange : NSMakeRange(text.length, 0); + NSUInteger selectionStart = MIN(selection.location, text.length); + NSUInteger selectionEnd = MIN(NSMaxRange(selection), text.length); + return @{ + @"text": text, + @"selectionStart": @(selectionStart), + @"selectionEnd": @(selectionEnd), + }; +} + +- (void)_notifyValueAndSelectionChangedWithEvent:(NSDictionary *)event +{ + NSString *text = event[@"text"]; + NSUInteger textLength = [text isKindOfClass:NSString.class] ? text.length : 0; + NSUInteger selectionStart = MIN([event[@"selectionStart"] unsignedIntegerValue], textLength); + NSUInteger selectionEnd = MIN([event[@"selectionEnd"] unsignedIntegerValue], textLength); + if (selectionEnd < selectionStart) { + selectionEnd = selectionStart; + } + _pendingSelection = NSMakeRange(selectionStart, selectionEnd - selectionStart); + _hasPendingSelection = YES; + [self valdi_didChangeValue:event[@"text"] forAttribute:@"value"]; + [self valdi_didChangeValue:@[event[@"selectionStart"], event[@"selectionEnd"]] forAttribute:@"selection"]; +} + +- (void)_notifyValueAndSelectionChanged +{ + [self _notifyValueAndSelectionChangedWithEvent:[self _editTextEvent]]; +} + +- (void)_notifySelectionChanged:(NSNotification *)notification +{ + NSText *editor = self.currentEditor; + if (editor == nil || notification.object != editor) { + return; + } + NSDictionary *event = [self _editTextEvent]; + NSUInteger selectionStart = [event[@"selectionStart"] unsignedIntegerValue]; + NSUInteger selectionEnd = [event[@"selectionEnd"] unsignedIntegerValue]; + _pendingSelection = NSMakeRange(selectionStart, selectionEnd - selectionStart); + _hasPendingSelection = YES; + [self valdi_didChangeValue:@[event[@"selectionStart"], event[@"selectionEnd"]] forAttribute:@"selection"]; +} + +- (NSDictionary *)_editTextEndEvent +{ + NSMutableDictionary *event = [[self _editTextEvent] mutableCopy]; + event[@"reason"] = @(_lastUnfocusReason); + return event; +} + +- (void)_submitEventToFunction:(SCValdiMacOSFunction *)func { if (!func) { return; } - [func performWithParameters:@[@{@"text": self.stringValue}]]; + [func performWithParameters:@[[self _editTextEvent]]]; +} + +- (void)_submitEditEndEventWithBaseEvent:(NSDictionary *)baseEvent +{ + if (!_onEditEnd) { + return; + } + + NSMutableDictionary *event = [baseEvent mutableCopy]; + event[@"reason"] = @(_lastUnfocusReason); + [_onEditEnd performWithParameters:@[event]]; } - (void)textDidChange:(NSNotification *)notification { [super textDidChange:notification]; - [self _submitEventToFuntion:_onChange]; + if (_onWillChange) { + id replacement = [_onWillChange performWithParametersAndReturnValue:@[[self _editTextEvent]]]; + if ([replacement isKindOfClass:NSDictionary.class]) { + NSString *replacementText = replacement[@"text"]; + NSNumber *selectionStartValue = replacement[@"selectionStart"]; + NSNumber *selectionEndValue = replacement[@"selectionEnd"]; + if ([replacementText isKindOfClass:NSString.class] && + [selectionStartValue isKindOfClass:NSNumber.class] && + [selectionEndValue isKindOfClass:NSNumber.class]) { + NSText *editor = self.currentEditor; + self.stringValue = replacementText; + editor.string = replacementText; + NSUInteger selectionStart = (NSUInteger)MAX( + 0, MIN((NSInteger)replacementText.length, selectionStartValue.integerValue)); + NSUInteger selectionEnd = (NSUInteger)MAX( + 0, MIN((NSInteger)replacementText.length, selectionEndValue.integerValue)); + if (selectionEnd < selectionStart) { + selectionEnd = selectionStart; + } + editor.selectedRange = NSMakeRange(selectionStart, selectionEnd - selectionStart); + } + } + } + [self _notifyValueAndSelectionChanged]; + [self _submitEventToFunction:_onChange]; } - (void)textDidBeginEditing:(NSNotification *)notification { [super textDidBeginEditing:notification]; - [self _submitEventToFuntion:_onEditBegin]; + _lastUnfocusReason = SCValdiMacOSTextInputUnfocusReasonUnknown; + NSText *editor = self.currentEditor; + if (editor != nil) { + _observedEditor = editor; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(_notifySelectionChanged:) + name:NSTextViewDidChangeSelectionNotification + object:editor]; + if (_selectTextOnFocus) { + editor.selectedRange = NSMakeRange(0, self.stringValue.length); + } else if (_hasPendingSelection) { + editor.selectedRange = _pendingSelection; + } + } + [self valdi_didChangeValue:@YES forAttribute:@"focused"]; + [self _notifyValueAndSelectionChanged]; + [self _submitEventToFunction:_onEditBegin]; } - (void)textDidEndEditing:(NSNotification *)notification { + NSDictionary *finalEvent = [self _editTextEvent]; [super textDidEndEditing:notification]; - [self _submitEventToFuntion:_onEditEnd]; + NSInteger textMovement = [notification.userInfo[NSTextMovementUserInfoKey] integerValue]; + if (textMovement == NSReturnTextMovement) { + _lastUnfocusReason = SCValdiMacOSTextInputUnfocusReasonReturnKeyPress; + } else if (textMovement == NSCancelTextMovement) { + _lastUnfocusReason = SCValdiMacOSTextInputUnfocusReasonDismissKeyPress; + } + [[NSNotificationCenter defaultCenter] removeObserver:self + name:NSTextViewDidChangeSelectionNotification + object:_observedEditor]; + _observedEditor = nil; + [self _notifyValueAndSelectionChangedWithEvent:finalEvent]; + [self valdi_didChangeValue:@NO forAttribute:@"focused"]; + [self _submitEditEndEventWithBaseEvent:finalEvent]; + _lastUnfocusReason = SCValdiMacOSTextInputUnfocusReasonUnknown; +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)viewDidMoveToWindow +{ + [super viewDidMoveToWindow]; + if (_shouldBecomeFocusedWhenAttached && self.window != nil) { + _shouldBecomeFocusedWhenAttached = NO; + [self.window makeFirstResponder:self]; + } +} + +- (BOOL)control:(NSControl *)control textView:(NSTextView *)textView doCommandBySelector:(SEL)commandSelector +{ + if (commandSelector == @selector(insertNewline:)) { + if (_closesWhenReturnKeyPressed) { + _lastUnfocusReason = SCValdiMacOSTextInputUnfocusReasonReturnKeyPress; + [self.window makeFirstResponder:nil]; + } + [self _submitEventToFunction:_onReturn]; + return YES; + } + if (commandSelector == @selector(cancelOperation:)) { + _lastUnfocusReason = SCValdiMacOSTextInputUnfocusReasonDismissKeyPress; + return NO; + } + if (commandSelector == @selector(deleteBackward:) || commandSelector == @selector(deleteForward:)) { + [self _submitEventToFunction:_onWillDelete]; + } + return NO; } - (void)_invalidatePlaceholder @@ -164,6 +338,11 @@ - (void)valdi_setEnabled:(id)enabled self.enabled = [enabled boolValue]; } +- (void)valdi_setEditable:(id)editable +{ + self.editable = editable == nil || [editable boolValue]; +} + - (void)valdi_setFont:(id)font { NSFont *nsFont = SCValdiResolveFont(font); @@ -176,10 +355,26 @@ - (void)valdi_setFont:(id)font - (void)valdi_setValue:(id)value { - if (value) { - [self setStringValue:value]; - } else { - [self setStringValue:@""]; + NSString *text = [value isKindOfClass:NSString.class] ? value : @""; + self.stringValue = text; + NSText *editor = self.currentEditor; + if (editor != nil) { + NSRange previousSelection = editor.selectedRange; + editor.string = text; + NSUInteger selectionStart = MIN(previousSelection.location, text.length); + NSUInteger selectionEnd = MIN(NSMaxRange(previousSelection), text.length); + if (selectionEnd < selectionStart) { + selectionEnd = selectionStart; + } + editor.selectedRange = NSMakeRange(selectionStart, selectionEnd - selectionStart); + } + if (_hasPendingSelection) { + NSUInteger selectionStart = MIN(_pendingSelection.location, text.length); + NSUInteger selectionEnd = MIN(NSMaxRange(_pendingSelection), text.length); + if (selectionEnd < selectionStart) { + selectionEnd = selectionStart; + } + _pendingSelection = NSMakeRange(selectionStart, selectionEnd - selectionStart); } } @@ -213,14 +408,68 @@ - (void)valdi_setOnChange:(id)value _onChange = value; } -// TODO implement the actual behaviour of onWillChange -// ios example: -// ../../ios/Views/SCValdiTextField.m#L130 - (void)valdi_setOnWillChange:(id)value { _onWillChange = value; } +- (void)valdi_setOnReturn:(id)value +{ + _onReturn = value; +} + +- (void)valdi_setOnWillDelete:(id)value +{ + _onWillDelete = value; +} + +- (void)valdi_setFocused:(id)value +{ + BOOL focused = value != nil && [value boolValue]; + if (focused) { + if (self.window != nil) { + [self.window makeFirstResponder:self]; + } else { + _shouldBecomeFocusedWhenAttached = YES; + } + } else { + _shouldBecomeFocusedWhenAttached = NO; + if (self.currentEditor != nil) { + [self.window makeFirstResponder:nil]; + } + } +} + +- (void)valdi_setSelection:(id)value +{ + _hasPendingSelection = NO; + if (![value isKindOfClass:[NSArray class]] || [value count] < 2) { + return; + } + NSUInteger textLength = self.stringValue.length; + NSUInteger selectionStart = (NSUInteger)MAX(0, MIN((NSInteger)textLength, [value[0] integerValue])); + NSUInteger selectionEnd = (NSUInteger)MAX(0, MIN((NSInteger)textLength, [value[1] integerValue])); + if (selectionEnd < selectionStart) { + selectionEnd = selectionStart; + } + _pendingSelection = NSMakeRange(selectionStart, selectionEnd - selectionStart); + _hasPendingSelection = YES; + NSText *editor = self.currentEditor; + if (editor != nil) { + editor.selectedRange = _pendingSelection; + } +} + +- (void)valdi_setSelectTextOnFocus:(id)value +{ + _selectTextOnFocus = value != nil && [value boolValue]; +} + +- (void)valdi_setClosesWhenReturnKeyPressed:(id)value +{ + _closesWhenReturnKeyPressed = value == nil || [value boolValue]; +} + - (void)valdi_setPlaceholder:(id)placeholder { _placeholderText = placeholder; @@ -230,6 +479,7 @@ - (void)valdi_setPlaceholder:(id)placeholder + (void)bindAttributes:(SCValdiMacOSAttributesBinder *)attributesBinder { [attributesBinder bindUntypedAttribute:@"enabled" invalidateLayoutOnChange:NO selector:@selector(valdi_setEnabled:)]; + [attributesBinder bindUntypedAttribute:@"editable" invalidateLayoutOnChange:NO selector:@selector(valdi_setEditable:)]; [attributesBinder bindUntypedAttribute:@"font" invalidateLayoutOnChange:YES selector:@selector(valdi_setFont:)]; [attributesBinder bindUntypedAttribute:@"value" invalidateLayoutOnChange:NO selector:@selector(valdi_setValue:)]; [attributesBinder bindUntypedAttribute:@"placeholder" invalidateLayoutOnChange:NO selector:@selector(valdi_setPlaceholder:)]; @@ -237,6 +487,12 @@ + (void)bindAttributes:(SCValdiMacOSAttributesBinder *)attributesBinder [attributesBinder bindUntypedAttribute:@"onEditEnd" invalidateLayoutOnChange:NO selector:@selector(valdi_setEditEnd:)]; [attributesBinder bindUntypedAttribute:@"onChange" invalidateLayoutOnChange:NO selector:@selector(valdi_setOnChange:)]; [attributesBinder bindUntypedAttribute:@"onWillChange" invalidateLayoutOnChange:NO selector:@selector(valdi_setOnWillChange:)]; + [attributesBinder bindUntypedAttribute:@"onReturn" invalidateLayoutOnChange:NO selector:@selector(valdi_setOnReturn:)]; + [attributesBinder bindUntypedAttribute:@"onWillDelete" invalidateLayoutOnChange:NO selector:@selector(valdi_setOnWillDelete:)]; + [attributesBinder bindUntypedAttribute:@"focused" invalidateLayoutOnChange:NO selector:@selector(valdi_setFocused:)]; + [attributesBinder bindUntypedAttribute:@"selection" invalidateLayoutOnChange:NO selector:@selector(valdi_setSelection:)]; + [attributesBinder bindUntypedAttribute:@"selectTextOnFocus" invalidateLayoutOnChange:NO selector:@selector(valdi_setSelectTextOnFocus:)]; + [attributesBinder bindUntypedAttribute:@"closesWhenReturnKeyPressed" invalidateLayoutOnChange:NO selector:@selector(valdi_setClosesWhenReturnKeyPressed:)]; [attributesBinder bindColorAttribute:@"color" invalidateLayoutOnChange:NO selector:@selector(valdi_setColor:)]; [attributesBinder bindColorAttribute:@"placeholderColor" invalidateLayoutOnChange:NO selector:@selector(valdi_setPlaceholderColor:)]; diff --git a/valdi/test/macos/SCValdiMacOSViewManagerTests.mm b/valdi/test/macos/SCValdiMacOSViewManagerTests.mm index 00fcb5d26..b6a156ab2 100644 --- a/valdi/test/macos/SCValdiMacOSViewManagerTests.mm +++ b/valdi/test/macos/SCValdiMacOSViewManagerTests.mm @@ -9,13 +9,108 @@ #import #import #import "valdi/macos/SCValdiMacOSViewManager.h" +#import "valdi/macos/SCValdiMacOSFunction.h" +#import "valdi/macos/Views/SCValdiMacOSTextField.h" #import "valdi/macos/SCValdiObjCUtils.h" +#include "valdi/runtime/Attributes/AttributeIds.hpp" +#include "valdi_core/cpp/Attributes/ColorPalette.hpp" #include "valdi_core/cpp/Utils/StringCache.hpp" +#include "valdi_core/cpp/Utils/ConsoleLogger.hpp" +#include "valdi_core/cpp/Utils/ValueFunctionWithCallable.hpp" #include "valdi/runtime/Attributes/BoundAttributes.hpp" +#include "valdi/runtime/Context/ViewNode.hpp" +#include "valdi/runtime/Interfaces/IViewTransaction.hpp" +#include "valdi/runtime/Utils/MainThreadManager.hpp" +#include "valdi/runtime/Views/DeferredViewTransaction.hpp" +#include "valdi/runtime/Views/ViewFactory.hpp" + +#include using namespace ValdiMacOS; using namespace Valdi; +@interface NSView (SCValdiAccessibilityTests) +- (BOOL)valdi_hasAttachedViewNode; +- (BOOL)valdi_hasAttachedViewNodeHandle; +- (BOOL)valdi_isAttachedToViewNode:(Valdi::ViewNode *)viewNode; +- (void)valdi_didChangeValue:(id)value forAttribute:(NSString *)attributeName; +- (void)valdi_setAccessibilityCategory:(nullable NSString *)category; +- (void)valdi_setAccessibilityStateDisabled:(nullable NSNumber *)disabled; +- (void)valdi_setAccessibilityStateSelected:(nullable NSNumber *)selected; +@end + +namespace { + +class QueuedMainThreadDispatcher final : public IMainThreadDispatcher { +public: + ~QueuedMainThreadDispatcher() override { + for (auto *function : _pendingFunctions) { + delete function; + } + } + + void dispatch(DispatchFunction *function, bool sync) override { + if (sync) { + (*function)(); + delete function; + } else { + _pendingFunctions.emplace_back(function); + } + } + + size_t pendingCount() const { + return _pendingFunctions.size(); + } + + void runNext() { + auto *function = _pendingFunctions.front(); + _pendingFunctions.erase(_pendingFunctions.begin()); + (*function)(); + delete function; + } + +private: + std::vector _pendingFunctions; +}; + +static Ref makeTestViewNode(AttributeIds& attributeIds) { + return makeShared(nullptr, attributeIds, nullptr, ConsoleLogger::getLogger()); +} + +} // namespace + +@interface SCValdiMacOSTextField (SCValdiMacOSTextFieldTests) +- (NSDictionary *)_editTextEvent; +- (NSDictionary *)_editTextEndEvent; +- (BOOL)control:(NSControl *)control textView:(NSTextView *)textView doCommandBySelector:(SEL)commandSelector; +- (void)valdi_setFocused:(nullable NSNumber *)focused; +- (void)valdi_setSelection:(nullable NSArray *)selection; +- (void)valdi_setOnChange:(nullable SCValdiMacOSFunction *)onChange; +- (void)valdi_setOnWillChange:(nullable SCValdiMacOSFunction *)onWillChange; +@end + +@interface SCValdiTrackingMacOSTextField : SCValdiMacOSTextField +@property (nonatomic, readonly) NSMutableDictionary *changedValues; +@end + +@implementation SCValdiTrackingMacOSTextField + +- (instancetype)initWithFrame:(NSRect)frameRect +{ + self = [super initWithFrame:frameRect]; + if (self) { + _changedValues = [NSMutableDictionary new]; + } + return self; +} + +- (void)valdi_didChangeValue:(id)value forAttribute:(NSString *)attributeName +{ + self.changedValues[attributeName] = value; +} + +@end + @interface SCValdiMacOSViewManagerTests : XCTestCase @property (nonatomic, assign) ViewManager* viewManager; @end @@ -79,4 +174,293 @@ - (void)testGetPlatformType { XCTAssertEqual(type, Valdi::PlatformTypeMacOS, @"MacOS ViewManager reports PlatformTypeMacOS"); } +- (void)testAccessibilityCategoryExposesSemanticRoleAndRestoresNativeRole { + SCValdiMacOSTextField *textField = [[SCValdiMacOSTextField alloc] initWithFrame:NSMakeRect(0, 0, 100, 30)]; + NSAccessibilityRole originalRole = textField.accessibilityRole; + + [textField valdi_setAccessibilityCategory:@"button"]; + + XCTAssertTrue(textField.isAccessibilityElement); + XCTAssertEqualObjects(textField.accessibilityRole, NSAccessibilityButtonRole); + + [textField valdi_setAccessibilityCategory:@"auto"]; + + XCTAssertEqualObjects(textField.accessibilityRole, originalRole); +} + +- (void)testAccessibilityStateMapsToNativeEnabledAndSelectedState { + NSView *view = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 100, 30)]; + + [view valdi_setAccessibilityStateDisabled:@YES]; + [view valdi_setAccessibilityStateSelected:@YES]; + + XCTAssertFalse(view.isAccessibilityEnabled); + XCTAssertTrue(view.isAccessibilitySelected); + + [view valdi_setAccessibilityStateDisabled:nil]; + [view valdi_setAccessibilityStateSelected:nil]; + + XCTAssertTrue(view.isAccessibilityEnabled); + XCTAssertFalse(view.isAccessibilitySelected); +} + +- (void)testRemovingAccessibilityDisabledPreservesDisabledControlState { + NSButton *button = [[NSButton alloc] initWithFrame:NSMakeRect(0, 0, 100, 30)]; + button.enabled = NO; + + [button valdi_setAccessibilityStateDisabled:@YES]; + [button valdi_setAccessibilityStateDisabled:nil]; + + XCTAssertFalse(button.isAccessibilityEnabled); + + button.enabled = YES; + [button valdi_setAccessibilityStateDisabled:@YES]; + button.enabled = YES; + [button valdi_setAccessibilityStateDisabled:nil]; + + XCTAssertTrue(button.isAccessibilityEnabled); +} + +- (void)testRetainedRootNativeViewDoesNotKeepViewNodeAlive { + auto factory = self.viewManager->createViewFactory(STRING_LITERAL("NSView"), nullptr); + auto view = factory->createView(nullptr, nullptr, false); + NSView *nativeView = fromValdiView(view); + AttributeIds attributeIds; + auto viewNode = makeTestViewNode(attributeIds); + auto weakViewNode = weakRef(viewNode.get()); + auto transaction = self.viewManager->createViewTransaction(nullptr, false); + + transaction->moveViewToTree(view, nullptr, viewNode.get()); + XCTAssertTrue([nativeView valdi_isAttachedToViewNode:viewNode.get()]); + + viewNode = nullptr; + + XCTAssertTrue(weakViewNode.expired()); + XCTAssertTrue([nativeView valdi_hasAttachedViewNodeHandle]); + XCTAssertFalse([nativeView valdi_hasAttachedViewNode]); + [nativeView valdi_didChangeValue:@"ignored" forAttribute:@"value"]; +} + +- (void)testDestroyingDetachedViewWrapperClearsNativeOverrideHandle { + auto factory = self.viewManager->createViewFactory(STRING_LITERAL("NSView"), nullptr); + auto view = factory->createView(nullptr, nullptr, false); + NSView *nativeView = fromValdiView(view); + AttributeIds attributeIds; + auto viewNode = makeTestViewNode(attributeIds); + auto transaction = self.viewManager->createViewTransaction(nullptr, false); + + transaction->moveViewToTree(view, nullptr, viewNode.get()); + XCTAssertTrue([nativeView valdi_isAttachedToViewNode:viewNode.get()]); + + view = nullptr; + + XCTAssertFalse([nativeView valdi_hasAttachedViewNodeHandle]); + XCTAssertFalse([nativeView valdi_hasAttachedViewNode]); +} + +- (void)testRemoveWithClearDropsNativeOverrideHandle { + auto factory = self.viewManager->createViewFactory(STRING_LITERAL("NSView"), nullptr); + auto view = factory->createView(nullptr, nullptr, false); + NSView *nativeView = fromValdiView(view); + AttributeIds attributeIds; + auto viewNode = makeTestViewNode(attributeIds); + auto transaction = self.viewManager->createViewTransaction(nullptr, false); + + transaction->moveViewToTree(view, nullptr, viewNode.get()); + transaction->removeViewFromParent(view, nullptr, true); + + XCTAssertFalse([nativeView valdi_hasAttachedViewNodeHandle]); + XCTAssertFalse([nativeView valdi_hasAttachedViewNode]); +} + +- (void)testDeferredMoveLeavesOnlyExpiredWeakHandleAfterOperationRuns { + auto factory = self.viewManager->createViewFactory(STRING_LITERAL("NSView"), nullptr); + auto view = factory->createView(nullptr, nullptr, false); + NSView *nativeView = fromValdiView(view); + AttributeIds attributeIds; + auto viewNode = makeTestViewNode(attributeIds); + auto weakViewNode = weakRef(viewNode.get()); + auto dispatcher = makeShared(); + auto mainThreadManager = makeShared(dispatcher); + auto transaction = makeShared(*self.viewManager, *mainThreadManager); + + transaction->moveViewToTree(view, nullptr, viewNode.get()); + transaction->flush(false); + viewNode = nullptr; + + XCTAssertEqual(dispatcher->pendingCount(), 1u); + XCTAssertFalse(weakViewNode.expired()); + XCTAssertFalse([nativeView valdi_hasAttachedViewNodeHandle]); + + dispatcher->runNext(); + + XCTAssertTrue(weakViewNode.expired()); + XCTAssertTrue([nativeView valdi_hasAttachedViewNodeHandle]); + XCTAssertFalse([nativeView valdi_hasAttachedViewNode]); + [nativeView valdi_didChangeValue:@"ignored" forAttribute:@"value"]; +} + +- (void)testDeferredRemovalIsSafeWhenViewNodeDiesBeforeDelivery { + auto factory = self.viewManager->createViewFactory(STRING_LITERAL("NSView"), nullptr); + auto view = factory->createView(nullptr, nullptr, false); + NSView *nativeView = fromValdiView(view); + AttributeIds attributeIds; + auto viewNode = makeTestViewNode(attributeIds); + auto directTransaction = self.viewManager->createViewTransaction(nullptr, false); + directTransaction->moveViewToTree(view, nullptr, viewNode.get()); + auto dispatcher = makeShared(); + auto mainThreadManager = makeShared(dispatcher); + auto deferredTransaction = makeShared(*self.viewManager, *mainThreadManager); + + deferredTransaction->removeViewFromParent(view, nullptr, true); + deferredTransaction->flush(false); + viewNode = nullptr; + + XCTAssertEqual(dispatcher->pendingCount(), 1u); + XCTAssertTrue([nativeView valdi_hasAttachedViewNodeHandle]); + XCTAssertFalse([nativeView valdi_hasAttachedViewNode]); + [nativeView valdi_didChangeValue:@"ignored" forAttribute:@"value"]; + + dispatcher->runNext(); + + XCTAssertFalse([nativeView valdi_hasAttachedViewNodeHandle]); +} + +- (void)testDeferredPoolEnqueueClearsNativeOverrideBeforeCallback { + auto factory = self.viewManager->createViewFactory(STRING_LITERAL("NSView"), nullptr); + auto view = factory->createView(nullptr, nullptr, false); + NSView *nativeView = fromValdiView(view); + AttributeIds attributeIds; + auto viewNode = makeTestViewNode(attributeIds); + auto directTransaction = self.viewManager->createViewTransaction(nullptr, false); + directTransaction->moveViewToTree(view, nullptr, viewNode.get()); + auto dispatcher = makeShared(); + auto mainThreadManager = makeShared(dispatcher); + auto deferredTransaction = makeShared(*self.viewManager, *mainThreadManager); + bool callbackCalled = false; + bool handleWasCleared = false; + + deferredTransaction->willEnqueueViewToPool(view, [&](View&) { + callbackCalled = true; + handleWasCleared = ![nativeView valdi_hasAttachedViewNodeHandle]; + }); + deferredTransaction->flush(false); + + XCTAssertEqual(dispatcher->pendingCount(), 1u); + XCTAssertTrue([nativeView valdi_isAttachedToViewNode:viewNode.get()]); + + dispatcher->runNext(); + + XCTAssertTrue(callbackCalled); + XCTAssertTrue(handleWasCleared); + XCTAssertFalse([nativeView valdi_hasAttachedViewNodeHandle]); +} + +- (void)testPhysicalTextEditingAppliesWillChangeAndSynchronizesNativeOverrides { + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 320, 200) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + SCValdiTrackingMacOSTextField *textField = + [[SCValdiTrackingMacOSTextField alloc] initWithFrame:NSMakeRect(10, 10, 200, 30)]; + textField.stringValue = @"seed"; + [textField valdi_setSelection:@[@1, @1]]; + [window.contentView addSubview:textField]; + + __block NSDictionary *changeEvent = nil; + SCValdiMacOSFunction *onWillChange = [[SCValdiMacOSFunction alloc] + initWithBlock:^id(NSArray *parameters) { + NSDictionary *event = parameters.firstObject; + NSString *uppercaseText = [event[@"text"] uppercaseString]; + return @{ + @"text": uppercaseText, + @"selectionStart": @(uppercaseText.length), + @"selectionEnd": @(uppercaseText.length), + }; + }]; + SCValdiMacOSFunction *onChange = [[SCValdiMacOSFunction alloc] + initWithBlock:^id(NSArray *parameters) { + changeEvent = parameters.firstObject; + return nil; + }]; + [textField valdi_setOnWillChange:onWillChange]; + [textField valdi_setOnChange:onChange]; + + XCTAssertTrue([window makeFirstResponder:textField]); + NSText *editor = textField.currentEditor; + XCTAssertNotNil(editor); + editor.string = @"draft"; + editor.selectedRange = NSMakeRange(5, 0); + [textField textDidChange:[NSNotification notificationWithName:NSControlTextDidChangeNotification object:textField]]; + + XCTAssertEqualObjects(textField.stringValue, @"DRAFT"); + XCTAssertEqualObjects(editor.string, @"DRAFT"); + XCTAssertEqual(editor.selectedRange.location, 5u); + XCTAssertEqualObjects(changeEvent[@"text"], @"DRAFT"); + XCTAssertEqualObjects(textField.changedValues[@"value"], @"DRAFT"); + XCTAssertEqualObjects(textField.changedValues[@"selection"], (@[@5, @5])); + XCTAssertEqualObjects(textField.changedValues[@"focused"], @YES); + + [textField valdi_setFocused:@NO]; + XCTAssertEqualObjects(textField.changedValues[@"focused"], @NO); + [textField valdi_setFocused:@YES]; + XCTAssertEqual(textField.currentEditor.selectedRange.location, 5u); + XCTAssertEqual(textField.currentEditor.selectedRange.length, 0u); + [textField valdi_setFocused:@NO]; + [window close]; +} + +- (void)testMacOSFunctionRequestsSynchronousReturnAndConvertsMaps { + ValueFunctionFlags receivedFlags = ValueFunctionFlagsNone; + auto cppFunction = makeShared( + [&receivedFlags](const ValueFunctionCallContext& callContext) -> Value { + receivedFlags = callContext.getFlags(); + return ValueFromNSObject(@{ + @"text": @"replacement", + @"selectionStart": @11, + @"selectionEnd": @11, + }); + }); + SCValdiMacOSFunction *function = + [[SCValdiMacOSFunction alloc] initWithCppInstance:(void *)cppFunction.get()]; + + NSDictionary *result = [function performWithParametersAndReturnValue:@[]]; + + XCTAssertNotNil(result); + XCTAssertEqualObjects(result[@"text"], @"replacement"); + XCTAssertEqualObjects(result[@"selectionStart"], @11); + XCTAssertTrue((receivedFlags & ValueFunctionFlagsCallSync) != ValueFunctionFlagsNone); +} + +- (void)testTextFieldChangeEventIncludesValueAndSelection { + SCValdiMacOSTextField *textField = [[SCValdiMacOSTextField alloc] initWithFrame:NSMakeRect(0, 0, 100, 30)]; + textField.stringValue = @"draft"; + NSDictionary *editTextEvent = [textField _editTextEvent]; + + XCTAssertEqualObjects(editTextEvent[@"text"], @"draft"); + XCTAssertEqualObjects(editTextEvent[@"selectionStart"], @5); + XCTAssertEqualObjects(editTextEvent[@"selectionEnd"], @5); +} + +- (void)testTextFieldEditEndEventIncludesUnknownReturnAndDismissReasons { + SCValdiMacOSTextField *unknownTextField = + [[SCValdiMacOSTextField alloc] initWithFrame:NSMakeRect(0, 0, 100, 30)]; + XCTAssertEqualObjects([unknownTextField _editTextEndEvent][@"reason"], @0); + + SCValdiMacOSTextField *returnTextField = + [[SCValdiMacOSTextField alloc] initWithFrame:NSMakeRect(0, 0, 100, 30)]; + NSTextView *fieldEditor = [[NSTextView alloc] initWithFrame:NSMakeRect(0, 0, 100, 30)]; + + [returnTextField control:returnTextField textView:fieldEditor doCommandBySelector:@selector(insertNewline:)]; + + XCTAssertEqualObjects([returnTextField _editTextEndEvent][@"reason"], @1); + + SCValdiMacOSTextField *dismissTextField = + [[SCValdiMacOSTextField alloc] initWithFrame:NSMakeRect(0, 0, 100, 30)]; + + [dismissTextField control:dismissTextField textView:fieldEditor doCommandBySelector:@selector(cancelOperation:)]; + + XCTAssertEqualObjects([dismissTextField _editTextEndEvent][@"reason"], @2); +} + @end