import { SIM_LONG_PRESS_MAX_MS } from './bridge-contract' // untrusted JSON -> bridge-contract argument. one owner for that conversion, so // every transport in front of a sim (the RNX Cloud sim service's HTTP and // WebSocket routes) validates its request bodies here rather than growing a // second reader for the same shapes. import type { ResetOptions, SemanticWaitOptions, SimSemanticQuery, SimSemanticResolveSelector, SimSemanticSelector, SimStateOptions, } from './bridge-contract' import type { SimPerformStep, SimScreenshotOptions } from './sim-client' import type { SootSimReplayTarget } from '@rnx/globals' /** the caller sent a value this bridge argument cannot be read from. */ export class BridgeInputError extends Error { constructor(message: string) { super(message) this.name = 'BridgeInputError' } } export const SEMANTIC_WAIT_MAX_TIMEOUT_MS = 30_000 function validationFailure(path: string, expectation: string): never { throw new BridgeInputError(`${path} ${expectation}`) } function isRecordValue(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } function recordValue(value: unknown, path: string): Record { if (!isRecordValue(value)) { validationFailure(path, 'must be an object') } return value } function stringValue(value: unknown, path: string): string { if (typeof value !== 'string') validationFailure(path, 'must be a string') return value } function numberValue(value: unknown, path: string): number { if (typeof value !== 'number' || !Number.isFinite(value)) { validationFailure(path, 'must be a finite number') } return value } function booleanValue(value: unknown, path: string): boolean { if (typeof value !== 'boolean') validationFailure(path, 'must be a boolean') return value } function optionalNumber( record: Record, key: string, path: string, ): number | undefined { return record[key] === undefined ? undefined : numberValue(record[key], `${path}.${key}`) } function optionalBoolean( record: Record, key: string, path: string, ): boolean | undefined { return record[key] === undefined ? undefined : booleanValue(record[key], `${path}.${key}`) } function semanticWaitTimeout( record: Record, path: string, ): number | undefined { const timeoutMs = optionalNumber(record, 'timeoutMs', path) if (timeoutMs !== undefined && timeoutMs > SEMANTIC_WAIT_MAX_TIMEOUT_MS) { validationFailure( `${path}.timeoutMs`, `must be at most ${SEMANTIC_WAIT_MAX_TIMEOUT_MS}`, ) } return timeoutMs } export function parsePerformStep(value: unknown, index: number): SimPerformStep { const path = `perform.steps[${index}]` const step = recordValue(value, path) const type = stringValue(step.type, `${path}.type`) switch (type) { case 'touchDown': case 'touchMove': case 'touchUp': { const pointerId = optionalNumber(step, 'pointerId', path) return { type, x: numberValue(step.x, `${path}.x`), y: numberValue(step.y, `${path}.y`), ...(pointerId === undefined ? {} : { pointerId }), } } case 'touchCancel': { const pointerId = optionalNumber(step, 'pointerId', path) return { type, ...(pointerId === undefined ? {} : { pointerId }) } } case 'tap': return { type, target: parseTapTarget(step.target), x: numberValue(step.x, `${path}.x`), y: numberValue(step.y, `${path}.y`), } case 'tapId': return { type, id: stringValue(step.id, `${path}.id`) } case 'tapText': return { type, text: stringValue(step.text, `${path}.text`) } case 'doubleTap': { const gapMs = optionalNumber(step, 'gapMs', path) return { type, x: numberValue(step.x, `${path}.x`), y: numberValue(step.y, `${path}.y`), ...(gapMs === undefined ? {} : { gapMs }), } } case 'longPress': { const durationMs = optionalNumber(step, 'durationMs', path) if ( durationMs !== undefined && (!Number.isInteger(durationMs) || durationMs < 1 || durationMs > SIM_LONG_PRESS_MAX_MS) ) { validationFailure( `${path}.durationMs`, `must be an integer between 1 and ${SIM_LONG_PRESS_MAX_MS}`, ) } return { type, x: numberValue(step.x, `${path}.x`), y: numberValue(step.y, `${path}.y`), ...(durationMs === undefined ? {} : { durationMs }), } } case 'drag': { const steps = optionalNumber(step, 'steps', path) const stepMs = optionalNumber(step, 'stepMs', path) return { type, fromX: numberValue(step.fromX, `${path}.fromX`), fromY: numberValue(step.fromY, `${path}.fromY`), toX: numberValue(step.toX, `${path}.toX`), toY: numberValue(step.toY, `${path}.toY`), ...(steps === undefined ? {} : { steps }), ...(stepMs === undefined ? {} : { stepMs }), } } case 'pinch': { const steps = optionalNumber(step, 'steps', path) const stepMs = optionalNumber(step, 'stepMs', path) return { type, fromX1: numberValue(step.fromX1, `${path}.fromX1`), fromY1: numberValue(step.fromY1, `${path}.fromY1`), fromX2: numberValue(step.fromX2, `${path}.fromX2`), fromY2: numberValue(step.fromY2, `${path}.fromY2`), toX1: numberValue(step.toX1, `${path}.toX1`), toY1: numberValue(step.toY1, `${path}.toY1`), toX2: numberValue(step.toX2, `${path}.toX2`), toY2: numberValue(step.toY2, `${path}.toY2`), ...(steps === undefined ? {} : { steps }), ...(stepMs === undefined ? {} : { stepMs }), } } case 'scroll': { const x = optionalNumber(step, 'x', path) const y = optionalNumber(step, 'y', path) const animated = optionalBoolean(step, 'animated', path) return { type, id: stringValue(step.id, `${path}.id`), ...(x === undefined ? {} : { x }), ...(y === undefined ? {} : { y }), ...(animated === undefined ? {} : { animated }), } } case 'type': return { type, text: stringValue(step.text, `${path}.text`) } case 'key': return { type, key: stringValue(step.key, `${path}.key`) } case 'dismissKeyboard': return { type } case 'setElementValue': { const submit = optionalBoolean(step, 'submit', path) return { type, id: stringValue(step.id, `${path}.id`), text: stringValue(step.text, `${path}.text`), ...(submit === undefined ? {} : { submit }), } } case 'toggleKeyboard': return { type } case 'openUrl': { const url = stringValue(step.url, `${path}.url`) if (url.length === 0) validationFailure(`${path}.url`, 'must be non-empty') return { type, url } } case 'keyDown': case 'keyUp': return { type, key: stringValue(step.key, `${path}.key`) } case 'setOrientation': { const orientation = stringValue(step.orientation, `${path}.orientation`) if (orientation !== 'portrait' && orientation !== 'landscape') { validationFailure(`${path}.orientation`, 'must be portrait or landscape') } return { type, orientation } } case 'deviceInfo': return { type } case 'buttonDown': case 'buttonUp': { const button = step.button === undefined ? undefined : stringValue(step.button, `${path}.button`) return { type, ...(button === undefined ? {} : { button }) } } case 'incrementElement': case 'decrementElement': return { type, id: stringValue(step.id, `${path}.id`) } case 'discoverStoreKitConfig': case 'clearStoreKitConfig': return { type } case 'wait': return { type, ms: numberValue(step.ms, `${path}.ms`) } case 'waitFor': { const timeoutMs = semanticWaitTimeout(step, path) const condition = step.condition === undefined ? undefined : stringValue(step.condition, `${path}.condition`) if ( condition !== undefined && condition !== 'present' && condition !== 'mounted' && condition !== 'absent' ) { return validationFailure( `${path}.condition`, 'must be present, mounted, or absent', ) } const selector = step.testID === undefined ? { id: stringValue(step.id, `${path}.id`) } : { testID: stringValue(step.testID, `${path}.testID`) } return { type, ...selector, ...(condition === undefined ? {} : { condition }), ...(timeoutMs === undefined ? {} : { timeoutMs }), } } default: return validationFailure(`${path}.type`, 'is not a supported perform step') } } // an unclosed touchDown leaves a pointer stuck down, and the batch abort path // would cancel it after the caller was told the batch failed. every batch is // self-contained (pointers never survive it), so a touchDown without its // touchUp or touchCancel in the same batch is rejected before anything runs. export function findUnbalancedTouchDown( steps: readonly SimPerformStep[], ): { index: number; pointerId: number } | null { const open = new Map() const pointerIdOf = (step: SimPerformStep): number | null => { if ( step.type !== 'touchDown' && step.type !== 'touchMove' && step.type !== 'touchUp' && step.type !== 'touchCancel' ) { return null } const pointerId = step.pointerId ?? 999 return Number.isSafeInteger(pointerId) ? pointerId : null } for (const [index, step] of steps.entries()) { if (step.type === 'touchDown') { const pointerId = pointerIdOf(step) ?? 999 if (!open.has(pointerId)) open.set(pointerId, index) } else if (step.type === 'touchUp' || step.type === 'touchCancel') { const pointerId = pointerIdOf(step) if (pointerId !== null) open.delete(pointerId) } } const next = open.entries().next() if (next.done) return null return { index: next.value[1], pointerId: next.value[0] } } export function parseStateOptions(value: unknown): SimStateOptions { const input = recordValue(value, 'state') const route = optionalBoolean(input, 'route', 'state') const errors = optionalBoolean(input, 'errors', 'state') const logs = optionalBoolean(input, 'logs', 'state') const depth = optionalNumber(input, 'depth', 'state') const errorLimit = optionalNumber(input, 'errorLimit', 'state') const logLimit = optionalNumber(input, 'logLimit', 'state') const layers = input.layers === undefined ? undefined : stringValue(input.layers, 'state.layers') if ( layers !== undefined && layers !== 'full' && layers !== 'tenant' && layers !== 'shell' ) { return validationFailure('state.layers', 'must be full, tenant, or shell') } return { screenshot: optionalBoolean(input, 'screenshot', 'state') ?? true, tree: optionalBoolean(input, 'tree', 'state') ?? true, ...(route === undefined ? {} : { route }), ...(errors === undefined ? {} : { errors }), ...(logs === undefined ? {} : { logs }), ...(depth === undefined ? {} : { depth }), ...(layers === undefined ? {} : { layers }), ...(errorLimit === undefined ? {} : { errorLimit }), ...(logLimit === undefined ? {} : { logLimit }), } } function parseSemanticSelector(value: unknown, path: string): SimSemanticSelector { const selector = recordValue(value, path) if (selector.testID !== undefined) { return { testID: stringValue(selector.testID, `${path}.testID`) } } if (selector.text !== undefined) { return { text: stringValue(selector.text, `${path}.text`) } } if (selector.role !== undefined) { return { role: stringValue(selector.role, `${path}.role`) } } if (selector.type !== undefined) { return { type: stringValue(selector.type, `${path}.type`) } } if (selector.pressable !== undefined) { if (booleanValue(selector.pressable, `${path}.pressable`) !== true) { return validationFailure(`${path}.pressable`, 'must be true') } return { pressable: true } } if (selector.visible !== undefined) { if (booleanValue(selector.visible, `${path}.visible`) !== true) { return validationFailure(`${path}.visible`, 'must be true') } return { visible: true } } return validationFailure( path, 'must select testID, text, role, type, pressable, or visible', ) } export function parseSemanticQuery(value: unknown): SimSemanticQuery { const query = recordValue(value, 'query') const kind = stringValue(query.kind, 'query.kind') if (kind === 'count') return { kind } if (kind === 'tree') { const depth = optionalNumber(query, 'depth', 'query') return { kind, ...(depth === undefined ? {} : { depth }) } } if (kind === 'find') { return { kind, selector: parseSemanticSelector(query.selector, 'query.selector') } } return validationFailure('query.kind', 'must be tree, find, or count') } function semanticResolveSelector( value: unknown, path: string, ): SimSemanticResolveSelector { const selector = recordValue(value, path) if (selector.testID !== undefined) { return { testID: stringValue(selector.testID, `${path}.testID`) } } if (selector.text !== undefined) { return { text: stringValue(selector.text, `${path}.text`) } } return validationFailure(path, 'must select testID or text') } export function parseSemanticResolveSelector(value: unknown): SimSemanticResolveSelector { return semanticResolveSelector(value, 'selector') } export function parseSemanticWaitOptions(value: unknown): SemanticWaitOptions { const input = recordValue(value, 'waitFor') const condition = recordValue(input.condition, 'waitFor.condition') const type = stringValue(condition.type, 'waitFor.condition.type') const timeoutMs = semanticWaitTimeout(input, 'waitFor') if (type === 'ready') { return { condition: { type }, ...(timeoutMs === undefined ? {} : { timeoutMs }), } } if (type !== 'present' && type !== 'mounted' && type !== 'absent') { return validationFailure( 'waitFor.condition.type', 'must be ready, present, mounted, or absent', ) } return { condition: { type, selector: semanticResolveSelector(condition.selector, 'waitFor.condition.selector'), }, ...(timeoutMs === undefined ? {} : { timeoutMs }), } } export function parseResetOptions(value: unknown): ResetOptions { const input = recordValue(value, 'reset') const strategy = input.strategy === undefined ? 'data' : stringValue(input.strategy, 'reset.strategy') if (strategy !== 'data' && strategy !== 'full') { return validationFailure('reset.strategy', 'must be data or full') } // the shape check only; the simulator service re-validates the id against // its own strict pattern and the caller's account before touching storage. const from = input.from if ( from !== undefined && (typeof from !== 'string' || from.length === 0 || from.length > 64) ) { return validationFailure('reset.from', 'must name a simulator') } return { strategy, relaunch: optionalBoolean(input, 'relaunch', 'reset') ?? true, ...(from === undefined ? {} : { from }), } } export function parseScreenshotOptions(value: unknown): SimScreenshotOptions { const input = recordValue(value, 'screenshot') const layers = input.layers === undefined ? undefined : stringValue(input.layers, 'screenshot.layers') if ( layers !== undefined && layers !== 'full' && layers !== 'tenant' && layers !== 'shell' ) { return validationFailure('screenshot.layers', 'must be full, tenant, or shell') } const crop = input.crop === undefined ? undefined : recordValue(input.crop, 'screenshot.crop') return { ...(layers === undefined ? {} : { layers }), ...(crop === undefined ? {} : { crop: { x: numberValue(crop.x, 'screenshot.crop.x'), y: numberValue(crop.y, 'screenshot.crop.y'), w: numberValue(crop.w, 'screenshot.crop.w'), h: numberValue(crop.h, 'screenshot.crop.h'), }, }), } } export function parseTapTarget(value: unknown): SootSimReplayTarget | undefined { if (value === undefined) return undefined const input = recordValue(value, 'tap.target') const target: SootSimReplayTarget = {} if (input.nodeId !== undefined && input.nodeId !== null) { const nodeId = numberValue(input.nodeId, 'tap.target.nodeId') if (!Number.isSafeInteger(nodeId)) validationFailure('tap.target.nodeId', 'must be an integer') target.nodeId = nodeId } for (const key of ['id', 'testID', 'text', 'type'] as const) { if (input[key] !== undefined && input[key] !== null) target[key] = stringValue(input[key], `tap.target.${key}`) } if (target.nodeId === undefined && !target.testID && !target.id) validationFailure('tap.target', 'requires nodeId, testID or id') return target }