From 0a6ab30203fa773ce97442684ebdbfd1896d3b4c Mon Sep 17 00:00:00 2001 From: Ken Tominaga Date: Tue, 24 Mar 2026 22:29:29 -0700 Subject: [PATCH] fix: restore interaction handler refactoring reverted by #245 PR #248 refactored interaction.ts into a thin routing layer delegating to focused command modules. PR #245 (gesture telemetry overlays) unintentionally reverted get/is/scrollintoview back to inline implementations due to a stale branch. This restores the thin router from #248, adapted for #245's handleTouchInteractionCommands, and removes interaction-press.ts and interaction-fill.ts which were made dead code by #245's interaction-touch.ts. --- src/daemon/handlers/interaction-fill.ts | 182 -------- src/daemon/handlers/interaction-press.ts | 243 ---------- src/daemon/handlers/interaction.ts | 553 ++--------------------- 3 files changed, 28 insertions(+), 950 deletions(-) delete mode 100644 src/daemon/handlers/interaction-fill.ts delete mode 100644 src/daemon/handlers/interaction-press.ts diff --git a/src/daemon/handlers/interaction-fill.ts b/src/daemon/handlers/interaction-fill.ts deleted file mode 100644 index da94f360f..000000000 --- a/src/daemon/handlers/interaction-fill.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { centerOfRect } from '../../utils/snapshot.ts'; -import { buildSelectorChainForNode, splitSelectorFromArgs } from '../selectors.ts'; -import { isFillableType, resolveRefLabel } from '../snapshot-processing.ts'; -import type { DaemonResponse } from '../types.ts'; -import type { InteractionHandlerParams } from './interaction-common.ts'; -import { refSnapshotFlagGuardResponse } from './interaction-flags.ts'; -import { resolveRefTarget } from './interaction-targeting.ts'; -import { resolveSelectorTarget } from './interaction-selector.ts'; - -export async function handleFillCommand(params: InteractionHandlerParams): Promise { - const { req, sessionName, sessionStore, contextFromFlags, dispatch } = params; - const session = sessionStore.get(sessionName); - if (session && !isCommandSupportedOnDevice('fill', session.device)) { - return { - ok: false, - error: { code: 'UNSUPPORTED_OPERATION', message: 'fill is not supported on this device' }, - }; - } - if (req.positionals?.[0]?.startsWith('@')) { - if (!session) { - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, - }; - } - const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('fill', req.flags); - if (invalidRefFlagsResponse) return invalidRefFlagsResponse; - const labelCandidate = req.positionals.length >= 3 ? req.positionals[1] : ''; - const text = - req.positionals.length >= 3 - ? req.positionals.slice(2).join(' ') - : req.positionals.slice(1).join(' '); - if (!text) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'fill requires text after ref' }, - }; - } - const resolvedRefTarget = resolveRefTarget({ - session, - refInput: req.positionals[0], - fallbackLabel: labelCandidate, - requireRect: true, - invalidRefMessage: 'fill requires a ref like @e2', - notFoundMessage: `Ref ${req.positionals[0]} not found or has no bounds`, - }); - if (!resolvedRefTarget.ok) return resolvedRefTarget.response; - const { ref, node, snapshotNodes } = resolvedRefTarget.target; - if (!node.rect) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `Ref ${req.positionals[0]} not found or has no bounds`, - }, - }; - } - const nodeType = node.type ?? ''; - const fillWarning = - nodeType && !isFillableType(nodeType, session.device.platform) - ? `fill target ${req.positionals[0]} resolved to "${nodeType}", attempting fill anyway.` - : undefined; - const refLabel = resolveRefLabel(node, snapshotNodes); - const selectorChain = buildSelectorChainForNode(node, session.device.platform, { - action: 'fill', - }); - const { x, y } = centerOfRect(node.rect); - const data = await dispatch( - session.device, - 'fill', - [String(x), String(y), text], - req.flags?.out, - { - ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - }, - ); - const resultPayload: Record = { - ...(data ?? { ref, x, y }), - }; - if (fillWarning) { - resultPayload.warning = fillWarning; - } - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { ...resultPayload, refLabel, selectorChain }, - }); - return { ok: true, data: resultPayload }; - } - if (!session) { - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, - }; - } - const selectorArgs = splitSelectorFromArgs(req.positionals ?? [], { - preferTrailingValue: true, - }); - if (!selectorArgs) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'fill requires x y text, @ref text, or selector text', - }, - }; - } - if (selectorArgs.rest.length === 0) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' }, - }; - } - const text = selectorArgs.rest.join(' ').trim(); - if (!text) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' }, - }; - } - const resolvedSelectorTarget = await resolveSelectorTarget({ - command: req.command, - selectorExpression: selectorArgs.selectorExpression, - session, - flags: req.flags, - sessionStore, - contextFromFlags, - interactiveOnly: true, - requireRect: true, - requireUnique: true, - disambiguateAmbiguous: true, - dispatch, - }); - if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget.response; - const { resolved, snapshot } = resolvedSelectorTarget; - const node = resolved.node; - if (!node.rect) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `Selector ${resolved.selector.raw} resolved to invalid bounds`, - }, - }; - } - const nodeType = node.type ?? ''; - const fillWarning = - nodeType && !isFillableType(nodeType, session.device.platform) - ? `fill target ${resolved.selector.raw} resolved to "${nodeType}", attempting fill anyway.` - : undefined; - const { x, y } = centerOfRect(node.rect); - const data = await dispatch( - session.device, - 'fill', - [String(x), String(y), text], - req.flags?.out, - { - ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - }, - ); - const selectorChain = buildSelectorChainForNode(node, session.device.platform, { - action: 'fill', - }); - const resultPayload: Record = { - ...(data ?? { x, y, text }), - selector: resolved.selector.raw, - selectorChain, - refLabel: resolveRefLabel(node, snapshot.nodes), - }; - if (fillWarning) { - resultPayload.warning = fillWarning; - } - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: resultPayload, - }); - return { ok: true, data: resultPayload }; -} diff --git a/src/daemon/handlers/interaction-press.ts b/src/daemon/handlers/interaction-press.ts deleted file mode 100644 index 453d9c88f..000000000 --- a/src/daemon/handlers/interaction-press.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { - buttonTag, - getClickButtonValidationError, - resolveClickButton, -} from '../../core/click-button.ts'; -import { buildSelectorChainForNode } from '../selectors.ts'; -import { findNodeByLabel, resolveRefLabel } from '../snapshot-processing.ts'; -import { findNodeByRef } from '../../utils/snapshot.ts'; -import type { DaemonResponse } from '../types.ts'; -import { refSnapshotFlagGuardResponse } from './interaction-flags.ts'; -import { - parseCoordinateTarget, - resolveRectCenter, - resolveRefTarget, -} from './interaction-targeting.ts'; -import { captureSnapshotForSession } from './interaction-snapshot.ts'; -import { resolveSelectorTarget } from './interaction-selector.ts'; -import type { InteractionHandlerParams } from './interaction-common.ts'; - -export async function handlePressCommand( - params: InteractionHandlerParams, -): Promise { - const { req, sessionName, sessionStore, contextFromFlags, dispatch } = params; - const commandLabel = req.command === 'click' ? 'click' : 'press'; - const session = sessionStore.get(sessionName); - if (!session) { - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, - }; - } - if (!isCommandSupportedOnDevice('press', session.device)) { - return { - ok: false, - error: { code: 'UNSUPPORTED_OPERATION', message: 'press is not supported on this device' }, - }; - } - const clickButton = resolveClickButton(req.flags); - const resultButtonTag = buttonTag(clickButton); - if (clickButton !== 'primary') { - const validationError = getClickButtonValidationError({ - commandLabel, - platform: session.device.platform, - button: clickButton, - count: req.flags?.count, - intervalMs: req.flags?.intervalMs, - holdMs: req.flags?.holdMs, - jitterPx: req.flags?.jitterPx, - doubleTap: req.flags?.doubleTap, - }); - if (validationError) { - return { - ok: false, - error: { - code: validationError.code, - message: validationError.message, - details: validationError.details, - }, - }; - } - } - const directCoordinates = parseCoordinateTarget(req.positionals ?? []); - if (directCoordinates) { - const data = await dispatch( - session.device, - 'press', - [String(directCoordinates.x), String(directCoordinates.y)], - req.flags?.out, - { - ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - }, - ); - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [String(directCoordinates.x), String(directCoordinates.y)], - flags: req.flags ?? {}, - result: data ?? { - x: directCoordinates.x, - y: directCoordinates.y, - ...resultButtonTag, - }, - }); - return { - ok: true, - data: - data ?? - ({ - x: directCoordinates.x, - y: directCoordinates.y, - ...resultButtonTag, - } as Record), - }; - } - - const refInput = req.positionals?.[0] ?? ''; - if (refInput.startsWith('@')) { - const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('press', req.flags); - if (invalidRefFlagsResponse) return invalidRefFlagsResponse; - const fallbackLabel = - req.positionals.length > 1 ? req.positionals.slice(1).join(' ').trim() : ''; - const resolvedRefTarget = resolveRefTarget({ - session, - refInput, - fallbackLabel, - requireRect: true, - invalidRefMessage: `${commandLabel} requires a ref like @e2`, - notFoundMessage: `Ref ${refInput} not found or has no bounds`, - }); - if (!resolvedRefTarget.ok) return resolvedRefTarget.response; - const { ref } = resolvedRefTarget.target; - let node = resolvedRefTarget.target.node; - let snapshotNodes = resolvedRefTarget.target.snapshotNodes; - let pressPoint = resolveRectCenter(node.rect); - if (!pressPoint) { - const refreshed = await captureSnapshotForSession( - session, - req.flags, - sessionStore, - contextFromFlags, - { interactiveOnly: true }, - dispatch, - ); - const refNode = findNodeByRef(refreshed.nodes, ref); - const fallbackNode = - fallbackLabel.length > 0 ? findNodeByLabel(refreshed.nodes, fallbackLabel) : null; - const fallbackNodePoint = resolveRectCenter(fallbackNode?.rect); - const refNodePoint = resolveRectCenter(refNode?.rect); - const refreshedNode = refNodePoint - ? refNode - : fallbackNodePoint - ? fallbackNode - : (refNode ?? fallbackNode); - const refreshedPoint = resolveRectCenter(refreshedNode?.rect); - if (refreshedNode && refreshedPoint) { - node = refreshedNode; - snapshotNodes = refreshed.nodes; - pressPoint = refreshedPoint; - } - } - if (!pressPoint) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `Ref ${refInput} not found or has invalid bounds`, - }, - }; - } - const refLabel = resolveRefLabel(node, snapshotNodes); - const selectorChain = buildSelectorChainForNode(node, session.device.platform, { - action: 'click', - }); - const { x, y } = pressPoint; - const data = await dispatch(session.device, 'press', [String(x), String(y)], req.flags?.out, { - ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - }); - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { - ref, - x, - y, - refLabel, - selectorChain, - ...resultButtonTag, - }, - }); - return { - ok: true, - data: { ...(data ?? {}), ref, x, y, ...resultButtonTag }, - }; - } - - const selectorExpression = (req.positionals ?? []).join(' ').trim(); - if (!selectorExpression) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: `${commandLabel} requires @ref, selector expression, or x y coordinates`, - }, - }; - } - const resolvedSelectorTarget = await resolveSelectorTarget({ - command: req.command, - selectorExpression, - session, - flags: req.flags, - sessionStore, - contextFromFlags, - interactiveOnly: true, - requireRect: true, - requireUnique: true, - disambiguateAmbiguous: true, - dispatch, - }); - if (!resolvedSelectorTarget.ok) return resolvedSelectorTarget.response; - const { resolved, snapshot } = resolvedSelectorTarget; - const pressPoint = resolveRectCenter(resolved.node.rect); - if (!pressPoint) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `Selector ${resolved.selector.raw} resolved to invalid bounds`, - }, - }; - } - const { x, y } = pressPoint; - const data = await dispatch(session.device, 'press', [String(x), String(y)], req.flags?.out, { - ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - }); - const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, { - action: 'click', - }); - const refLabel = resolveRefLabel(resolved.node, snapshot.nodes); - sessionStore.recordAction(session, { - command: req.command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { - x, - y, - selector: resolved.selector.raw, - selectorChain, - refLabel, - ...resultButtonTag, - }, - }); - return { - ok: true, - data: { - ...(data ?? {}), - selector: resolved.selector.raw, - x, - y, - ...resultButtonTag, - }, - }; -} diff --git a/src/daemon/handlers/interaction.ts b/src/daemon/handlers/interaction.ts index 988f7be22..b13e08607 100644 --- a/src/daemon/handlers/interaction.ts +++ b/src/daemon/handlers/interaction.ts @@ -1,62 +1,29 @@ -import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts'; -import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { - attachRefs, - centerOfRect, - findNodeByRef, - normalizeRef, - type RawSnapshotNode, - type Rect, - type SnapshotNode, -} from '../../utils/snapshot.ts'; -import type { DaemonCommandContext } from '../context.ts'; -import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import { SessionStore } from '../session-store.ts'; -import { evaluateIsPredicate, isSupportedPredicate } from '../is-predicates.ts'; -import { - extractNodeText, - findNodeByLabel, - pruneGroupNodes, - resolveRefLabel, -} from '../snapshot-processing.ts'; -import { - buildSelectorChainForNode, - findSelectorChainMatch, - formatSelectorFailure, - parseSelectorChain, - resolveSelectorChain, - splitIsSelectorArgs, -} from '../selectors.ts'; -import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; -import { buildScrollIntoViewPlan, resolveViewportRect } from '../scroll-planner.ts'; +import { dispatchCommand } from '../../core/dispatch.ts'; +import type { DaemonResponse } from '../types.ts'; import { getAndroidScreenSize } from '../../platforms/android/index.ts'; +import type { InteractionHandlerParams } from './interaction-common.ts'; import { handleTouchInteractionCommands } from './interaction-touch.ts'; - -type ContextFromFlags = ( - flags: CommandFlags | undefined, - appBundleId?: string, - traceLogPath?: string, -) => DaemonCommandContext; - -export async function handleInteractionCommands(params: { - req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; - contextFromFlags: ContextFromFlags; - dispatch?: typeof dispatchCommand; - readAndroidScreenSize?: typeof getAndroidScreenSize; -}): Promise { - const { req, sessionName, sessionStore, contextFromFlags } = params; +import { handleGetCommand } from './interaction-get.ts'; +import { handleIsCommand } from './interaction-is.ts'; +import { handleScrollIntoViewCommand } from './interaction-scroll.ts'; +import { captureSnapshotForSession } from './interaction-snapshot.ts'; +import { resolveRefTarget } from './interaction-targeting.ts'; +import { refSnapshotFlagGuardResponse } from './interaction-flags.ts'; + +export { unsupportedRefSnapshotFlags } from './interaction-flags.ts'; + +export async function handleInteractionCommands( + params: Omit & { + dispatch?: typeof dispatchCommand; + readAndroidScreenSize?: typeof getAndroidScreenSize; + }, +): Promise { const dispatch = params.dispatch ?? dispatchCommand; const readAndroidScreenSize = params.readAndroidScreenSize ?? getAndroidScreenSize; - const command = req.command; + const handlerParams: InteractionHandlerParams = { ...params, dispatch }; const touchResponse = await handleTouchInteractionCommands({ - req, - sessionName, - sessionStore, - contextFromFlags, - dispatch, + ...handlerParams, readAndroidScreenSize, captureSnapshotForSession, resolveRefTarget, @@ -66,478 +33,14 @@ export async function handleInteractionCommands(params: { return touchResponse; } - if (command === 'get') { - const sub = req.positionals?.[0]; - if (sub !== 'text' && sub !== 'attrs') { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'get only supports text or attrs' }, - }; - } - const session = sessionStore.get(sessionName); - if (!session) { - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, - }; - } - if (!isCommandSupportedOnDevice('get', session.device)) { - return { - ok: false, - error: { code: 'UNSUPPORTED_OPERATION', message: 'get is not supported on this device' }, - }; - } - const refInput = req.positionals?.[1] ?? ''; - if (refInput.startsWith('@')) { - const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('get', req.flags); - if (invalidRefFlagsResponse) return invalidRefFlagsResponse; - const labelCandidate = - req.positionals.length > 2 ? req.positionals.slice(2).join(' ').trim() : ''; - const resolvedRefTarget = resolveRefTarget({ - session, - refInput, - fallbackLabel: labelCandidate, - requireRect: false, - invalidRefMessage: 'get text requires a ref like @e2', - notFoundMessage: `Ref ${refInput} not found`, - }); - if (!resolvedRefTarget.ok) return resolvedRefTarget.response; - const { ref, node } = resolvedRefTarget.target; - const selectorChain = buildSelectorChainForNode(node, session.device.platform, { - action: 'get', - }); - if (sub === 'attrs') { - sessionStore.recordAction(session, { - command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { ref, selectorChain }, - }); - return { ok: true, data: { ref, node } }; - } - const text = extractNodeText(node); - sessionStore.recordAction(session, { - command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { ref, text, refLabel: text || undefined, selectorChain }, - }); - return { ok: true, data: { ref, text, node } }; - } - - const selectorExpression = req.positionals.slice(1).join(' ').trim(); - if (!selectorExpression) { - return { - ok: false, - error: { code: 'INVALID_ARGS', message: 'get requires @ref or selector expression' }, - }; - } - const chain = parseSelectorChain(selectorExpression); - const snapshot = await captureSnapshotForSession( - session, - req.flags, - sessionStore, - contextFromFlags, - { interactiveOnly: false }, - dispatch, - ); - const resolved = await withDiagnosticTimer( - 'selector_resolve', - () => - resolveSelectorChain(snapshot.nodes, chain, { - platform: session.device.platform, - requireRect: false, - requireUnique: true, - disambiguateAmbiguous: sub === 'text', - }), - { command }, - ); - if (!resolved) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: formatSelectorFailure(chain, [], { unique: true }), - }, - }; - } - const node = resolved.node; - const selectorChain = buildSelectorChainForNode(node, session.device.platform, { - action: 'get', - }); - if (sub === 'attrs') { - sessionStore.recordAction(session, { - command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { selector: resolved.selector.raw, selectorChain }, - }); - return { ok: true, data: { selector: resolved.selector.raw, node } }; - } - const text = extractNodeText(node); - sessionStore.recordAction(session, { - command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { - text, - refLabel: text || undefined, - selector: resolved.selector.raw, - selectorChain, - }, - }); - return { ok: true, data: { selector: resolved.selector.raw, text, node } }; - } - - if (command === 'is') { - const predicate = (req.positionals?.[0] ?? '').toLowerCase(); - if (!isSupportedPredicate(predicate)) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'is requires predicate: visible|hidden|exists|editable|selected|text', - }, - }; - } - const session = sessionStore.get(sessionName); - if (!session) { - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, - }; - } - if (!isCommandSupportedOnDevice('is', session.device)) { - return { - ok: false, - error: { code: 'UNSUPPORTED_OPERATION', message: 'is is not supported on this device' }, - }; - } - const { split } = splitIsSelectorArgs(req.positionals); - if (!split) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'is requires a selector expression', - }, - }; - } - const expectedText = split.rest.join(' ').trim(); - if (predicate === 'text' && !expectedText) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: 'is text requires expected text value', - }, - }; - } - if (predicate !== 'text' && split.rest.length > 0) { - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: `is ${predicate} does not accept trailing values`, - }, - }; - } - const chain = parseSelectorChain(split.selectorExpression); - const snapshot = await captureSnapshotForSession( - session, - req.flags, - sessionStore, - contextFromFlags, - { interactiveOnly: false }, - dispatch, - ); - if (predicate === 'exists') { - const matched = findSelectorChainMatch(snapshot.nodes, chain, { - platform: session.device.platform, - }); - if (!matched) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: formatSelectorFailure(chain, [], { unique: false }), - }, - }; - } - sessionStore.recordAction(session, { - command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { - predicate, - selector: matched.selector.raw, - selectorChain: chain.selectors.map((entry) => entry.raw), - pass: true, - matches: matched.matches, - }, - }); - return { - ok: true, - data: { predicate, pass: true, selector: matched.selector.raw, matches: matched.matches }, - }; - } - - const resolved = await withDiagnosticTimer( - 'selector_resolve', - () => - resolveSelectorChain(snapshot.nodes, chain, { - platform: session.device.platform, - requireUnique: true, - }), - { command: 'is', predicate }, - ); - if (!resolved) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: formatSelectorFailure(chain, [], { unique: true }), - }, - }; - } - const result = evaluateIsPredicate({ - predicate, - node: resolved.node, - expectedText, - platform: session.device.platform, - }); - if (!result.pass) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `is ${predicate} failed for selector ${resolved.selector.raw}: ${result.details}`, - }, - }; - } - sessionStore.recordAction(session, { - command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { - predicate, - selector: resolved.selector.raw, - selectorChain: chain.selectors.map((entry) => entry.raw), - pass: true, - text: predicate === 'text' ? result.actualText : undefined, - }, - }); - return { ok: true, data: { predicate, pass: true, selector: resolved.selector.raw } }; - } - - if (command === 'scrollintoview') { - const session = sessionStore.get(sessionName); - if (!session) { - return { - ok: false, - error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, - }; - } - if (!isCommandSupportedOnDevice('scrollintoview', session.device)) { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'scrollintoview is not supported on this device', - }, - }; - } - const targetInput = req.positionals?.[0] ?? ''; - if (!targetInput.startsWith('@')) { + switch (params.req.command) { + case 'get': + return await handleGetCommand(handlerParams); + case 'is': + return await handleIsCommand(handlerParams); + case 'scrollintoview': + return await handleScrollIntoViewCommand(handlerParams); + default: return null; - } - const invalidRefFlagsResponse = refSnapshotFlagGuardResponse('scrollintoview', req.flags); - if (invalidRefFlagsResponse) return invalidRefFlagsResponse; - const fallbackLabel = - req.positionals && req.positionals.length > 1 - ? req.positionals.slice(1).join(' ').trim() - : ''; - const resolvedRefTarget = resolveRefTarget({ - session, - refInput: targetInput, - fallbackLabel, - requireRect: true, - invalidRefMessage: 'scrollintoview requires a ref like @e2', - notFoundMessage: `Ref ${targetInput} not found or has no bounds`, - }); - if (!resolvedRefTarget.ok) return resolvedRefTarget.response; - const { ref, node, snapshotNodes } = resolvedRefTarget.target; - if (!node.rect) { - return { - ok: false, - error: { code: 'COMMAND_FAILED', message: `Ref ${targetInput} not found or has no bounds` }, - }; - } - const viewportRect = resolveViewportRect(snapshotNodes, node.rect); - if (!viewportRect) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `scrollintoview could not infer viewport for ${targetInput}`, - }, - }; - } - const plan = buildScrollIntoViewPlan(node.rect, viewportRect); - const refLabel = resolveRefLabel(node, snapshotNodes); - const selectorChain = buildSelectorChainForNode(node, session.device.platform, { - action: 'get', - }); - if (!plan) { - sessionStore.recordAction(session, { - command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { ref, attempts: 0, alreadyVisible: true, refLabel, selectorChain }, - }); - return { ok: true, data: { ref, attempts: 0, alreadyVisible: true } }; - } - const data = await dispatch( - session.device, - 'swipe', - [String(plan.x), String(plan.startY), String(plan.x), String(plan.endY), '16'], - req.flags?.out, - { - ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - count: plan.count, - pauseMs: 0, - pattern: 'one-way', - }, - ); - sessionStore.recordAction(session, { - command, - positionals: req.positionals ?? [], - flags: req.flags ?? {}, - result: { - ...(data ?? {}), - ref, - attempts: plan.count, - direction: plan.direction, - refLabel, - selectorChain, - }, - }); - return { - ok: true, - data: { - ...(data ?? {}), - ref, - attempts: plan.count, - direction: plan.direction, - }, - }; - } - - return null; -} - -async function captureSnapshotForSession( - session: SessionState, - flags: CommandFlags | undefined, - sessionStore: SessionStore, - contextFromFlags: ContextFromFlags, - options: { interactiveOnly: boolean }, - dispatch: typeof dispatchCommand = dispatchCommand, -) { - const data = (await dispatch(session.device, 'snapshot', [], flags?.out, { - ...contextFromFlags( - { - ...(flags ?? {}), - snapshotInteractiveOnly: options.interactiveOnly, - snapshotCompact: options.interactiveOnly, - }, - session.appBundleId, - session.trace?.outPath, - ), - })) as { - nodes?: RawSnapshotNode[]; - truncated?: boolean; - backend?: 'xctest' | 'android'; - }; - const rawNodes = data?.nodes ?? []; - const nodes = attachRefs(flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes)); - session.snapshot = { - nodes, - truncated: data?.truncated, - createdAt: Date.now(), - backend: data?.backend, - }; - sessionStore.set(session.name, session); - return session.snapshot; -} - -const REF_UNSUPPORTED_FLAG_MAP: ReadonlyArray<[keyof CommandFlags, string]> = [ - ['snapshotDepth', '--depth'], - ['snapshotScope', '--scope'], - ['snapshotRaw', '--raw'], -]; - -function refSnapshotFlagGuardResponse( - command: 'press' | 'fill' | 'get' | 'scrollintoview', - flags: CommandFlags | undefined, -): DaemonResponse | null { - const unsupported = unsupportedRefSnapshotFlags(flags); - if (unsupported.length === 0) return null; - return { - ok: false, - error: { - code: 'INVALID_ARGS', - message: `${command} @ref does not support ${unsupported.join(', ')}.`, - }, - }; -} - -export function unsupportedRefSnapshotFlags(flags: CommandFlags | undefined): string[] { - if (!flags) return []; - const unsupported: string[] = []; - for (const [key, label] of REF_UNSUPPORTED_FLAG_MAP) { - if (flags[key] !== undefined) unsupported.push(label); - } - return unsupported; -} - -function resolveRefTarget(params: { - session: SessionState; - refInput: string; - fallbackLabel: string; - requireRect: boolean; - invalidRefMessage: string; - notFoundMessage: string; -}): - | { ok: true; target: { ref: string; node: SnapshotNode; snapshotNodes: SnapshotNode[] } } - | { ok: false; response: DaemonResponse } { - const { session, refInput, fallbackLabel, requireRect, invalidRefMessage, notFoundMessage } = - params; - if (!session.snapshot) { - return { - ok: false, - response: { - ok: false, - error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' }, - }, - }; - } - const ref = normalizeRef(refInput); - if (!ref) { - return { - ok: false, - response: { ok: false, error: { code: 'INVALID_ARGS', message: invalidRefMessage } }, - }; - } - let node = findNodeByRef(session.snapshot.nodes, ref); - if ((!node || (requireRect && !node.rect)) && fallbackLabel.length > 0) { - node = findNodeByLabel(session.snapshot.nodes, fallbackLabel); - } - if (!node || (requireRect && !node.rect)) { - return { - ok: false, - response: { ok: false, error: { code: 'COMMAND_FAILED', message: notFoundMessage } }, - }; } - return { ok: true, target: { ref, node, snapshotNodes: session.snapshot.nodes } }; }