/** * ActionExecutor — Programmatically triggers UI actions on registered elements. * * Uses the element handlers captured during registration to simulate * real user interactions. Each action method takes a RegisteredElement * and executes the appropriate handler. */ import type { RegisteredElement } from './types'; export interface ActionResult { success: boolean; action: string; elementId: string; error?: string; data?: Record; } export class ActionExecutor { /** Tracks cumulative scroll offsets per element (scrollTo uses absolute coords) */ private scrollOffsets = new Map(); /** * Tap (press) an element. */ tap(element: RegisteredElement): ActionResult { const { onPress } = element.handlers; if (!onPress) { return { success: false, action: 'tap', elementId: element.info.id, error: 'Element does not have an onPress handler', }; } if (element.info.state.disabled) { return { success: false, action: 'tap', elementId: element.info.id, error: 'Element is disabled', }; } try { onPress(); return { success: true, action: 'tap', elementId: element.info.id, data: { elementTapped: true }, }; } catch (e: any) { return { success: false, action: 'tap', elementId: element.info.id, error: `onPress threw: ${e.message}`, }; } } /** * Long press an element. */ longPress(element: RegisteredElement): ActionResult { const { onLongPress } = element.handlers; if (!onLongPress) { return { success: false, action: 'longPress', elementId: element.info.id, error: 'Element does not have an onLongPress handler', }; } if (element.info.state.disabled) { return { success: false, action: 'longPress', elementId: element.info.id, error: 'Element is disabled', }; } try { onLongPress(); return { success: true, action: 'longPress', elementId: element.info.id, data: { elementLongPressed: true }, }; } catch (e: any) { return { success: false, action: 'longPress', elementId: element.info.id, error: `onLongPress threw: ${e.message}`, }; } } /** * Type text into a TextInput element. */ type(element: RegisteredElement, text: string): ActionResult { const { onChangeText } = element.handlers; if (!onChangeText) { return { success: false, action: 'type', elementId: element.info.id, error: 'Element does not have an onChangeText handler', }; } if (element.info.state.disabled) { return { success: false, action: 'type', elementId: element.info.id, error: 'Element is disabled', }; } try { // Append text to existing value for realistic typing behavior const currentValue = element.info.value || ''; const newValue = currentValue + text; onChangeText(newValue); // Update the registry's stored value element.info.value = newValue; return { success: true, action: 'type', elementId: element.info.id, data: { typed: text, newValue }, }; } catch (e: any) { return { success: false, action: 'type', elementId: element.info.id, error: `onChangeText threw: ${e.message}`, }; } } /** * Clear a TextInput element. */ clear(element: RegisteredElement): ActionResult { const { onChangeText } = element.handlers; if (!onChangeText) { return { success: false, action: 'clear', elementId: element.info.id, error: 'Element does not have an onChangeText handler', }; } try { onChangeText(''); element.info.value = ''; return { success: true, action: 'clear', elementId: element.info.id, data: { cleared: true }, }; } catch (e: any) { return { success: false, action: 'clear', elementId: element.info.id, error: `clear threw: ${e.message}`, }; } } /** * Scroll a ScrollView element. * Tracks cumulative offset since scrollTo() requires absolute positions. */ scroll( element: RegisteredElement, direction: 'up' | 'down' | 'left' | 'right', amount: number = 300 ): ActionResult { const { scrollTo } = element.handlers; if (!scrollTo) { return { success: false, action: 'scroll', elementId: element.info.id, error: 'Element does not have a scrollTo handler', }; } try { // scrollTo() takes absolute coordinates, so we track cumulative offsets const key = element.info.id; const current = this.scrollOffsets.get(key) ?? { x: 0, y: 0 }; const delta: Record = { up: { x: 0, y: -amount }, down: { x: 0, y: amount }, left: { x: -amount, y: 0 }, right: { x: amount, y: 0 }, }; const d = delta[direction]!; const newOffset = { x: Math.max(0, current.x + d.x), y: Math.max(0, current.y + d.y), }; this.scrollOffsets.set(key, newOffset); scrollTo({ ...newOffset, animated: true }); return { success: true, action: 'scroll', elementId: element.info.id, data: { direction, amount, scrollPosition: newOffset }, }; } catch (e: any) { return { success: false, action: 'scroll', elementId: element.info.id, error: `scrollTo threw: ${e.message}`, }; } } /** * Toggle a Switch element. */ toggle(element: RegisteredElement): ActionResult { const { onValueChange } = element.handlers; if (!onValueChange) { return { success: false, action: 'toggle', elementId: element.info.id, error: 'Element does not have an onValueChange handler', }; } if (element.info.state.disabled) { return { success: false, action: 'toggle', elementId: element.info.id, error: 'Element is disabled', }; } try { // Use element.info.state.checked which is: // - Correctly set by the Introspector from the Switch's value prop // - Immediately updated here after each toggle // - Refreshed by the next periodic scan // We avoid reading the fiber's memoizedProps.value because React's // state update is async — the fiber value is stale until re-render. const currentValue = element.info.state.checked ?? false; onValueChange(!currentValue); element.info.state.checked = !currentValue; return { success: true, action: 'toggle', elementId: element.info.id, data: { newValue: !currentValue }, }; } catch (e: any) { return { success: false, action: 'toggle', elementId: element.info.id, error: `onValueChange threw: ${e.message}`, }; } } /** * Set the value of a slider or similar element. */ setValue(element: RegisteredElement, value: number): ActionResult { const { onValueChange } = element.handlers; if (!onValueChange) { return { success: false, action: 'setValue', elementId: element.info.id, error: 'Element does not have an onValueChange handler', }; } try { onValueChange(value); // Update stored value in the element info element.info.value = String(value); return { success: true, action: 'setValue', elementId: element.info.id, data: { newValue: value }, }; } catch (e: any) { return { success: false, action: 'setValue', elementId: element.info.id, error: `onValueChange threw: ${e.message}`, }; } } /** * Swipe an element in a direction. */ swipe( element: RegisteredElement, direction: 'left' | 'right' | 'up' | 'down' ): ActionResult { const { onSwipe } = element.handlers; if (!onSwipe) { return { success: false, action: 'swipe', elementId: element.info.id, error: 'Element does not have an onSwipe handler', }; } try { onSwipe(direction); return { success: true, action: 'swipe', elementId: element.info.id, data: { direction }, }; } catch (e: any) { return { success: false, action: 'swipe', elementId: element.info.id, error: `onSwipe threw: ${e.message}`, }; } } /** * Read the current text/value of an element. */ read(element: RegisteredElement): ActionResult { return { success: true, action: 'read', elementId: element.info.id, data: { label: element.info.label, value: element.info.value, type: element.info.type, state: element.info.state, }, }; } } /** Singleton instance */ export const actionExecutor = new ActionExecutor();