import { BridgeInputError } from '../../src/bridge-contract-input' import type { SimPerformStep } from '../../src/sim-client' interface TargetedDoubleTapAction { type: 'targetedDoubleTap' id: string gapMs: number } interface TargetedLongPressAction { type: 'targetedLongPress' id: string durationMs: number } export type DoChainAction = | SimPerformStep | TargetedDoubleTapAction | TargetedLongPressAction const DO_CHAIN_ACTIONS = new Set([ 'tap', 'tap-id', 'tap-text', 'double-tap', 'long-press', 'type', 'type-into', 'key', 'key-sequence', 'dismiss', 'scroll', 'drag', 'swipe', 'pinch', 'touch', 'sleep', ]) export function hasDoChain(args: string[]): boolean { return args.includes('--then') } function invalidAction(index: number, usage: string): never { throw new BridgeInputError( `do chain action ${index + 1} has invalid arguments; usage: rnx do ${usage}`, ) } function finiteNumber(value: string | undefined, index: number, usage: string): number { const parsed = Number(value) if (value === undefined || !Number.isFinite(parsed)) invalidAction(index, usage) return parsed } export function parseDoChainActions(args: string[]): DoChainAction[] { const segments: string[][] = [[]] for (const argument of args) { if (argument !== '--then') { segments[segments.length - 1].push(argument) continue } if (segments[segments.length - 1].length === 0) { throw new BridgeInputError('do chain contains an empty action') } segments.push([]) } if (segments[segments.length - 1].length === 0) { throw new BridgeInputError('do chain cannot end with --then') } return segments.flatMap((segment, index): DoChainAction[] => { const [action, ...values] = segment if (!action) throw new BridgeInputError('do chain contains an empty action') if (!DO_CHAIN_ACTIONS.has(action)) { throw new BridgeInputError( `do chain action ${index + 1} cannot batch ${JSON.stringify(action)}; run it as a separate rnx do command`, ) } switch (action) { case 'tap': { if (values.length === 1 && !Number.isFinite(Number(values[0]))) { return [{ type: 'tapId', id: values[0] }] } if (values.length !== 2) invalidAction(index, 'tap | ') return [ { type: 'tap', x: finiteNumber(values[0], index, 'tap | '), y: finiteNumber(values[1], index, 'tap | '), }, ] } case 'tap-id': if (values.length !== 1) invalidAction(index, 'tap-id ') return [{ type: 'tapId', id: values[0] }] case 'tap-text': { const text = values.join(' ') if (!text) invalidAction(index, 'tap-text ') return [{ type: 'tapText', text }] } case 'double-tap': { const usage = 'double-tap [gapMs] | [gapMs]' if (values.length > 0 && !Number.isFinite(Number(values[0]))) { if (values.length > 2) invalidAction(index, usage) return [ { type: 'targetedDoubleTap', id: values[0], gapMs: values.length === 2 ? Math.max(0, Math.round(finiteNumber(values[1], index, usage))) : 80, }, ] } if (values.length < 2 || values.length > 3) invalidAction(index, usage) return [ { type: 'doubleTap', x: finiteNumber(values[0], index, usage), y: finiteNumber(values[1], index, usage), gapMs: values.length === 3 ? Math.max(0, Math.round(finiteNumber(values[2], index, usage))) : 80, }, ] } case 'long-press': { const usage = 'long-press [durationMs] | [durationMs]' if (values.length > 0 && !Number.isFinite(Number(values[0]))) { if (values.length > 2) invalidAction(index, usage) return [ { type: 'targetedLongPress', id: values[0], durationMs: values.length === 2 ? Math.max(0, Math.round(finiteNumber(values[1], index, usage))) : 600, }, ] } if (values.length < 2 || values.length > 3) invalidAction(index, usage) return [ { type: 'longPress', x: finiteNumber(values[0], index, usage), y: finiteNumber(values[1], index, usage), durationMs: values.length === 3 ? Math.max(0, Math.round(finiteNumber(values[2], index, usage))) : 600, }, ] } case 'type': { const text = values.join(' ') if (!text) invalidAction(index, 'type ') return [{ type: 'type', text }] } case 'type-into': { const text = values.slice(1).join(' ') if (!values[0] || !text) invalidAction(index, 'type-into ') return [ { type: 'tapId', id: values[0] }, { type: 'type', text }, ] } case 'key': if (values.length !== 1) invalidAction(index, 'key ') return [{ type: 'key', key: values[0] }] case 'key-sequence': if (values.length === 0) invalidAction(index, 'key-sequence [ ...]') return values.map((key) => ({ type: 'key', key })) case 'dismiss': if (values.length !== 0) invalidAction(index, 'dismiss') return [{ type: 'dismissKeyboard' }] case 'scroll': { const usage = 'scroll ' if (values.length !== 3) invalidAction(index, usage) return [ { type: 'scroll', id: values[0], x: finiteNumber(values[1], index, usage), y: finiteNumber(values[2], index, usage), animated: false, }, ] } case 'drag': case 'swipe': { const usage = `${action} [steps] [stepMs]` if (values.length < 4 || values.length > 6) invalidAction(index, usage) const defaultSteps = action === 'swipe' ? 10 : 12 const defaultStepMs = action === 'swipe' ? 8 : 16 return [ { type: 'drag', fromX: finiteNumber(values[0], index, usage), fromY: finiteNumber(values[1], index, usage), toX: finiteNumber(values[2], index, usage), toY: finiteNumber(values[3], index, usage), steps: values.length >= 5 ? Math.max(1, Math.round(finiteNumber(values[4], index, usage))) : defaultSteps, stepMs: values.length === 6 ? Math.max(0, Math.round(finiteNumber(values[5], index, usage))) : defaultStepMs, }, ] } case 'pinch': { const usage = "pinch [steps] [stepMs]" if (values.length < 8 || values.length > 10) invalidAction(index, usage) return [ { type: 'pinch', fromX1: finiteNumber(values[0], index, usage), fromY1: finiteNumber(values[1], index, usage), fromX2: finiteNumber(values[2], index, usage), fromY2: finiteNumber(values[3], index, usage), toX1: finiteNumber(values[4], index, usage), toY1: finiteNumber(values[5], index, usage), toX2: finiteNumber(values[6], index, usage), toY2: finiteNumber(values[7], index, usage), steps: values.length >= 9 ? Math.max(1, Math.round(finiteNumber(values[8], index, usage))) : 12, stepMs: values.length === 10 ? Math.max(0, Math.round(finiteNumber(values[9], index, usage))) : 16, }, ] } case 'touch': { const usage = 'touch [pointerId]' if (values.length < 3 || values.length > 4) invalidAction(index, usage) const phase = values[0] if ( phase !== 'down' && phase !== 'move' && phase !== 'up' && phase !== 'cancel' ) { invalidAction(index, usage) } const pointerId = values.length === 4 ? Math.max(1, Math.round(finiteNumber(values[3], index, usage))) : 999 if (phase === 'cancel') return [{ type: 'touchCancel', pointerId }] return [ { type: phase === 'down' ? 'touchDown' : phase === 'move' ? 'touchMove' : 'touchUp', x: finiteNumber(values[1], index, usage), y: finiteNumber(values[2], index, usage), pointerId, }, ] } case 'sleep': { const usage = 'sleep [seconds]' if (values.length > 1) invalidAction(index, usage) const seconds = values.length === 1 ? finiteNumber(values[0], index, usage) : 0.5 if (seconds < 0) invalidAction(index, usage) return [{ type: 'wait', ms: Math.round(seconds * 1000) }] } default: throw new BridgeInputError( `do chain action ${index + 1} cannot batch ${JSON.stringify(action)}`, ) } }) } export async function runDoChain( args: string[], opts: { port?: number } = {}, ): Promise { const [connectModule, simClientModule, bridgeModule, envModule, targetModule] = await Promise.all([ import('../../src/connect'), import('../../src/sim-client'), import('../ws-bridge'), import('./inspect/env'), import('./inspect/resolve-target'), ]) const { connect } = connectModule const { SimPerformError } = simClientModule const { checkSimHealth, parseBridgeCliArgs } = bridgeModule const { isAgentEnv } = envModule const { resolveTargetCoords } = targetModule const wantsJson = args.includes('--json') const skipSettle = args.includes('--no-wait') || process.env.RNX_NO_AUTO_WAIT === '1' const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: 30_000, stripBooleanFlags: ['--json', '--no-wait'], }) if (!Number.isFinite(parsed.commandTimeoutMs) || parsed.commandTimeoutMs <= 0) { console.error(' do chain: --timeout must be a positive number') return 1 } let actions: DoChainAction[] try { actions = parseDoChainActions(parsed.positional) } catch (error) { console.error(` do chain: ${error instanceof Error ? error.message : error}`) return 1 } let sim: Awaited> | null = null try { sim = await connect({ port: parsed.wsPort, sim: parsed.simIdSource === 'flag' ? parsed.simId : undefined, timeoutMs: parsed.commandTimeoutMs, }) await checkSimHealth(sim) const steps: SimPerformStep[] = [] for (const action of actions) { if (action.type !== 'targetedDoubleTap' && action.type !== 'targetedLongPress') { steps.push(action) continue } const target = await resolveTargetCoords(sim, { mode: 'testid', value: action.id }) if (!target) { throw new BridgeInputError( `do chain could not find testID ${JSON.stringify(action.id)}`, ) } steps.push( action.type === 'targetedDoubleTap' ? { type: 'doubleTap', x: target.x, y: target.y, gapMs: action.gapMs, } : { type: 'longPress', x: target.x, y: target.y, durationMs: action.durationMs, }, ) } const result = await sim.perform(steps, { timeoutMs: parsed.commandTimeoutMs }) if (!skipSettle) { try { const budgetMs = isAgentEnv() ? 400 : 200 const settled = await sim.settle({ maxMs: budgetMs }) if (!settled.settled) { process.stderr.write( ` ⚠ auto-wait timed out after ${settled.elapsed ?? budgetMs}ms. next command may see mid-animation state. use \`rnx do settle\` for a longer wait.\n`, ) } } catch {} } if (wantsJson) { console.log(JSON.stringify(result)) } else { console.log( ` completed ${result.completed} batched ${result.completed === 1 ? 'step' : 'steps'} in ${result.durationMs}ms`, ) } return 0 } catch (error) { if (error instanceof SimPerformError && wantsJson) { console.log(JSON.stringify(error.result)) } else { console.error( ` do chain failed: ${error instanceof Error ? error.message : error}`, ) } return 1 } finally { sim?.close() } }