import { pollUntil } from '../src/poll-until' // pull in @rnx/globals so window.__sootsimTest and // window.__sootsimFindInteractiveCanvas are typed inside page.evaluate // bodies. all sootsim-namespaced globals are declared canonically there — // add new entries to packages/rnx-globals/src/index.ts, not here. import type {} from '@rnx/globals' import type { Page } from 'playwright' // shape of the surface-metrics bridge response we care about. the bridge // types it as `Record | null` (the contract is open) so we // narrow at the call sites by casting to this local view. type SurfaceMetricsView = { window?: { width?: number; height?: number; scale?: number } device?: { homeIndicatorHeight?: number } safeAreaInsets?: { bottom?: number } visibleRect?: { y?: number; height?: number } } type SurfaceMetricsResult = SurfaceMetricsView | null | undefined type SootsimPageMetrics = { canvas: { left: number; top: number; width: number; height: number } window: { width: number; height: number } interactiveWindow: { width: number; height: number } } type WorkletCallbackStats = { tenantReceived: number shellSent: number } export function resolveScrollStartPosition( value: number | undefined, label: string, ): number { const normalized = value === undefined || Number.isNaN(value) ? 0.5 : value if (!Number.isFinite(normalized) || normalized < 0 || normalized > 1) { throw new Error(`${label} must be between 0 and 1`) } return normalized } type ShellGestureSeamDebug = { attached?: Array<{ viewTag?: unknown nodeFound?: unknown handlers?: Array<{ name?: unknown }> }> } export async function waitForSootsimGestureHandler( page: Page, viewTag: number, handlerName: string, ): Promise { await pollUntil( () => page.evaluate( async ({ viewTag, handlerName }) => { const host = ( window as typeof window & { SootSim?: { bridges?: { shellHost?: { callTestBridge?: ( method: string, ...args: unknown[] ) => Promise } | null } } } ).SootSim?.bridges?.shellHost if (typeof host?.callTestBridge !== 'function') return false let debug: ShellGestureSeamDebug | null = null try { debug = await host.callTestBridge('getShellGestureSeamDebug') } catch { return false } return ( debug?.attached?.some( (entry) => entry.viewTag === viewTag && entry.nodeFound !== false && entry.handlers?.some((handler) => handler.name === handlerName), ) === true ) }, { viewTag, handlerName }, ), Boolean, { timeoutMs: 5000, intervalMs: 100, label: `${handlerName} gesture handler on view ${viewTag}`, }, ) } async function readWorkletCallbackStats( page: Page, ): Promise { return page.evaluate(async () => { type Stats = { runJSCallbackSent?: unknown runJSCallbackReceived?: unknown } | null const g = window as typeof window & { __sootsimTest?: { getWorkletSlotStats?: () => Promise | Stats } SootSim?: { bridges?: { shellHost?: { callTestBridge?: (method: string, ...args: unknown[]) => Promise } | null } } } const tenant = typeof g.__sootsimTest?.getWorkletSlotStats === 'function' ? await g.__sootsimTest.getWorkletSlotStats() : null const shellHost = g.SootSim?.bridges?.shellHost let shell: Stats = null if (typeof shellHost?.callTestBridge === 'function') { try { shell = await shellHost.callTestBridge('getWorkletSlotStats') } catch {} } const tenantReceived = typeof tenant?.runJSCallbackReceived === 'number' ? tenant.runJSCallbackReceived : 0 const shellSent = typeof shell?.runJSCallbackSent === 'number' ? shell.runJSCallbackSent : 0 return { tenantReceived, shellSent } }) } async function waitForWorkletCallbackDrain( page: Page, before: WorkletCallbackStats | null, timeoutMs: number, ): Promise { if (!before) { await page.waitForTimeout(250) return } let lastShellSent = before.shellSent let stableSince = Date.now() let sawCallback = false await pollUntil( () => readWorkletCallbackStats(page), (current) => { if (!current) return false if (current.shellSent !== lastShellSent) { lastShellSent = current.shellSent stableSince = Date.now() } const sentDelta = current.shellSent - before.shellSent if (sentDelta > 0) sawCallback = true const receivedDelta = current.tenantReceived - before.tenantReceived return sawCallback && receivedDelta >= sentDelta && Date.now() - stableSince >= 100 }, { timeoutMs, intervalMs: 20, label: 'worklet callback drain', }, ).catch(() => {}) if (!sawCallback) { // Some gestures are purely native and do not schedule RN callbacks. A // bounded fallback still preserves Detox's "gesture has settled" contract. await page.waitForTimeout(250) } } // helper script injected via page.addInitScript so every page.evaluate // can reach it as `__sootsimFindInteractiveCanvas()`. picks the canvas // that actually receives pointer input. sootsim renders multiple // layers per surface (background/render targets, hidden // offscreen, overlay) and `document.querySelector('canvas')` returns // the first one in DOM order — usually a non-interactive layer with // `pointer-events: none`. dispatching synthetic PointerEvents onto // that layer silently no-ops because the gesture system only listens // on the interactive layer (pointer-events: auto, non-zero rect). const sootsimCanvasFinderInstalled = new WeakSet() function installInteractiveCanvasFinder() { window.__sootsimFindInteractiveCanvas = (): HTMLCanvasElement | null => { const interactive: Array<{ c: HTMLCanvasElement; z: number; app: boolean }> = [] const appVisible: Array<{ c: HTMLCanvasElement; z: number; app: boolean }> = [] for (const c of Array.from(document.querySelectorAll('canvas'))) { const s = window.getComputedStyle(c) if (s.display === 'none' || s.visibility === 'hidden') continue const r = c.getBoundingClientRect() if (r.width === 0 || r.height === 0) continue const entry = { c, z: parseInt(s.zIndex, 10) || 0, app: c.dataset.surfaceId?.startsWith('app:') === true, } if (entry.app) appVisible.push(entry) if (s.pointerEvents !== 'none') interactive.push(entry) } const list = appVisible.length > 0 ? appVisible : interactive if (list.length === 0) return null list.sort((a, b) => Number(b.app) - Number(a.app) || b.z - a.z) return list[0].c } } async function ensureCanvasFinder(page: Page) { if (!sootsimCanvasFinderInstalled.has(page)) { sootsimCanvasFinderInstalled.add(page) await page.addInitScript(installInteractiveCanvasFinder) } await page.evaluate(installInteractiveCanvasFinder) } async function readSootsimPageMetrics( page: Page, timeoutMs: number, ): Promise { await ensureCanvasFinder(page) try { const metrics = await pollUntil( () => page.evaluate(async () => { const canvas = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector('canvas') if (!canvas) return null const rect = canvas.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return null const surface = (await window.__sootsimTest?.getSurfaceMetricsSnapshot?.()) as SurfaceMetricsResult const screenWidth = surface?.window?.width const screenHeight = surface?.window?.height if ( typeof screenWidth !== 'number' || typeof screenHeight !== 'number' || screenWidth <= 0 || screenHeight <= 0 ) { return null } const homeIndicatorHeight = Number(surface?.device?.homeIndicatorHeight) || 0 const safeAreaBottom = Number(surface?.safeAreaInsets?.bottom) || 0 const bottomInset = Math.max(0, homeIndicatorHeight, safeAreaBottom) const visibleRectBottom = Number(surface?.visibleRect?.y) + Number(surface?.visibleRect?.height) const interactiveHeight = Number.isFinite(visibleRectBottom) && visibleRectBottom > 0 ? Math.min(screenHeight, visibleRectBottom) : Math.max(0, screenHeight - bottomInset) return { canvas: { left: rect.left, top: rect.top, width: rect.width, height: rect.height, }, window: { width: screenWidth, height: screenHeight, }, interactiveWindow: { width: screenWidth, height: interactiveHeight, }, } }), (metrics) => metrics !== null, { timeoutMs, intervalMs: 50, label: 'sootsim surface metrics', }, ) if (metrics === null) throw new Error('surface metrics poll settled without metrics') return metrics } catch (error) { throw new Error('sootsim surface metrics are not available', { cause: error }) } } export async function readSootsimViewport( page: Page, timeoutMs = 5000, ): Promise<{ width: number; height: number }> { return (await readSootsimPageMetrics(page, timeoutMs)).window } export async function readSootsimInteractiveViewport( page: Page, timeoutMs = 5000, ): Promise<{ width: number; height: number }> { return (await readSootsimPageMetrics(page, timeoutMs)).interactiveWindow } export async function sootsimToPage( page: Page, sootsimX: number, sootsimY: number, timeoutMs = 5000, ): Promise<{ x: number; y: number }> { const metrics = await readSootsimPageMetrics(page, timeoutMs) const scaleX = metrics.canvas.width / metrics.window.width const scaleY = metrics.canvas.height / metrics.window.height return { x: metrics.canvas.left + sootsimX * scaleX, y: metrics.canvas.top + sootsimY * scaleY, } } export async function dragScrollNode( page: Page, node: { absolutePosition: { x: number; y: number } layout: { width: number; height: number } }, pixels: number, direction: 'up' | 'down' | 'left' | 'right', startPositionX?: number, startPositionY?: number, ): Promise { const normalizedStartX = resolveScrollStartPosition( startPositionX, 'scroll startPositionX', ) const normalizedStartY = resolveScrollStartPosition( startPositionY, 'scroll startPositionY', ) const startSootSimX = node.absolutePosition.x + node.layout.width * normalizedStartX const startSootSimY = node.absolutePosition.y + node.layout.height * normalizedStartY let endSootSimX = startSootSimX let endSootSimY = startSootSimY switch (direction) { case 'down': endSootSimY -= pixels break case 'up': endSootSimY += pixels break case 'left': endSootSimX += pixels break case 'right': endSootSimX -= pixels break } const startPage = await sootsimToPage(page, startSootSimX, startSootSimY) const endPage = await sootsimToPage(page, endSootSimX, endSootSimY) const steps = Math.max(16, Math.ceil(pixels / 8)) await page.mouse.move(startPage.x, startPage.y) await page.mouse.down() await page.waitForTimeout(20) const dx = (endPage.x - startPage.x) / steps const dy = (endPage.y - startPage.y) / steps for (let i = 1; i <= steps; i++) { await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i) await page.waitForTimeout(24) } await page.mouse.up() await page.waitForTimeout(180) } // Fire synthetic two-finger pointer events directly on the sootsim canvas. We // use PointerEvents (not page.mouse) because page.mouse is single-cursor; // sootsim's touch tracker keys on pointerId, so we just need to dispatch // events with two distinct ids and pointerType 'touch'. Used for pinch / // rotation drivers — RNGH's PinchGesture/RotationGesture both require ≥2 // concurrent active touches on iOS UIPinchGestureRecognizer parity. export async function dispatchTwoFingerGesture( page: Page, startA: { x: number; y: number }, startB: { x: number; y: number }, endA: { x: number; y: number }, endB: { x: number; y: number }, options: { steps?: number stepDelayMs?: number workletStatsTimeoutMs?: number } = {}, ): Promise { const steps = options.steps ?? 10 const stepDelay = options.stepDelayMs ?? 18 const workletStatsTimeoutMs = options.workletStatsTimeoutMs ?? 1500 await ensureCanvasFinder(page) const beforeWorkletStats = await pollUntil( () => readWorkletCallbackStats(page), () => true, { timeoutMs: workletStatsTimeoutMs, intervalMs: 0, label: 'pre-dispatch worklet callback stats', }, ).catch(() => null) await page.evaluate( ({ startA, startB }) => { const canvas: HTMLCanvasElement | null = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector('canvas') if (!canvas) throw new Error('sootsim interactive canvas not found') const fire = (point: { x: number; y: number }, id: number, primary: boolean) => { canvas.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true, cancelable: true, clientX: point.x, clientY: point.y, isPrimary: primary, pointerId: id, pointerType: 'touch', }), ) } fire(startA, 1001, true) fire(startB, 1002, false) }, { startA, startB }, ) for (let i = 1; i <= steps; i++) { const t = i / steps const ax = startA.x + (endA.x - startA.x) * t const ay = startA.y + (endA.y - startA.y) * t const bx = startB.x + (endB.x - startB.x) * t const by = startB.y + (endB.y - startB.y) * t await page.evaluate( ({ ax, ay, bx, by }) => { const canvas: HTMLCanvasElement | null = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector('canvas') if (!canvas) return canvas.dispatchEvent( new PointerEvent('pointermove', { bubbles: true, cancelable: true, clientX: ax, clientY: ay, isPrimary: true, pointerId: 1001, pointerType: 'touch', }), ) canvas.dispatchEvent( new PointerEvent('pointermove', { bubbles: true, cancelable: true, clientX: bx, clientY: by, isPrimary: false, pointerId: 1002, pointerType: 'touch', }), ) }, { ax, ay, bx, by }, ) await page.waitForTimeout(stepDelay) } await page.evaluate( ({ endA, endB }) => { const canvas: HTMLCanvasElement | null = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector('canvas') if (!canvas) return canvas.dispatchEvent( new PointerEvent('pointerup', { bubbles: true, cancelable: true, clientX: endA.x, clientY: endA.y, isPrimary: true, pointerId: 1001, pointerType: 'touch', }), ) canvas.dispatchEvent( new PointerEvent('pointerup', { bubbles: true, cancelable: true, clientX: endB.x, clientY: endB.y, isPrimary: false, pointerId: 1002, pointerType: 'touch', }), ) }, { endA, endB }, ) await waitForWorkletCallbackDrain(page, beforeWorkletStats, workletStatsTimeoutMs) // runOnJS delivery increments before the callback's React setState commits. // Detox gestures settle after native UI has observed the gesture result, so // leave one bounded commit window after the worklet bridge drains. await page.waitForTimeout(250) }