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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/docs/command-line-references.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.<br></br>

`valdi inspect input <capabilities|query|tap|focus|text|key|scroll> [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.<br></br>

`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.<br></br>

Expand Down
61 changes: 59 additions & 2 deletions docs/docs/workflow-inspector.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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



21 changes: 19 additions & 2 deletions npm_modules/cli/debugger/debugger-bootstrap.js
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down Expand Up @@ -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
Expand Down
138 changes: 132 additions & 6 deletions npm_modules/cli/debugger/debugger-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading