// detox-compatible test driver for sootsim // drop-in replacement for `import { by, device, element, expect, waitFor } from 'detox'` // uses playwright to control a headless browser running sootsim import * as fs from 'fs' import * as path from 'path' import { chromium } from 'playwright' import { launchReapedChromeScoped } from '../../../scripts/lib/reap-browser' import { readBrowserCompositeCaptureRequest, resolveBrowserCompositeCdpClip, } from '../../sootsim-engine/src/capture/browser-composite' import { pollUntil } from '../src/poll-until' import { createExpect, createWaitFor, describeNodeVisibility, isNodeVisible, } from './expectations' import { dispatchTwoFingerGesture, dragScrollNode, readSootsimInteractiveViewport, readSootsimViewport, resolveScrollStartPosition, sootsimToPage, waitForSootsimGestureHandler, } from './gestures' import { by, type Matcher } from './matchers' import { MOTION_CHANGE_DEFINITION, measureMotionChange, type MotionChangeDefinition, type MotionChangeMeasurement, } from './motion-change.cjs' import { describeSootsimBridgeFailure, navigateSootsimPage } from './navigation-lifecycle' import { REQUIRED_IDENTICAL_PROOF_FRAMES, framesIdentical } from './proof-frame.cjs' import type { SootSimElement } from './element-types' import type { Browser, BrowserContext, Page } from 'playwright' export { by } export type { SootSimElement } from './element-types' const BASE_URL = process.env.RNX_URL || 'http://localhost:5173' const SCREENSHOT_DIR = process.env.RNX_SCREENSHOT_DIR || path.join(process.cwd(), 'test', 'detox-driver', 'screenshots') const DETOX_PLATFORM = process.env.RNX_PLATFORM === 'android' ? ('android' as const) : ('ios' as const) // shared state -- playwright page and browser let _browser: Browser | null = null let _reapBrowserNow: (() => void) | null = null let _context: BrowserContext | null = null let _page: Page | null = null let _synchronizationEnabled = false let _suspendedAppId: string | null = null let _browserCompromised: string | null = null let browserIdentity = 0 let contextIdentity = 0 let nextBrowserIdentity = 0 let nextContextIdentity = 0 // maximum duration for browser/context lifecycle operations (closeContext, // newContext, newPage). playwright does not bound context.close() or context // creation; this bound converts an opaque 180s Jest hook timeout into a // named failure that identifies which lifecycle step stalled. const BROWSER_LIFECYCLE_TIMEOUT_MS = 30_000 async function withLifecycleTimeout( actionName: string, targetContextId: number, fn: () => Promise, timeoutMs = BROWSER_LIFECYCLE_TIMEOUT_MS, ): Promise { const start = Date.now() let timer: ReturnType | undefined try { return await Promise.race([ fn(), new Promise((_, reject) => { timer = setTimeout(() => { const elapsed = Date.now() - start reject( new Error( `sootsim browser lifecycle error: ${actionName} did not settle after ${elapsed}ms (context=${targetContextId})`, ), ) }, timeoutMs) }), ]) } finally { if (timer) clearTimeout(timer) } } const STATUS_BAR_OVERRIDE_EVENT = 'sootsim:statusBarOverride' type StatusBarConfig = { time?: string dataNetwork?: string wifiMode?: string wifiBars?: string cellularMode?: string cellularBars?: string operatorName?: string batteryState?: string batteryLevel?: string | number } function getPage(): Page { if (_browserCompromised) { throw new Error(`sootsim browser is compromised: ${_browserCompromised}`) } if (!_page) throw new Error('sootsim driver not initialized -- call device.launchApp() first') return _page } async function waitForSootsimTree(page: Page, timeout = 30000): Promise { try { await page.waitForFunction(() => !!window.__sootsimTest?.waitForTree, { timeout, }) await page.evaluate(async (timeoutMs) => { await Promise.race([ window.__sootsimTest!.waitForTree(), new Promise((_, reject) => { setTimeout( () => reject(new Error(`sootsim waitForTree timed out after ${timeoutMs}ms`)), timeoutMs, ) }), ]) }, timeout) } catch (error) { throw await describeSootsimBridgeFailure(page, error) } } async function waitForSootsimSurfaceMetrics(page: Page, timeout = 30000): Promise { const deadline = Date.now() + timeout let lastSnapshot: unknown = null while (Date.now() < deadline) { lastSnapshot = await page.evaluate(async () => { const metricsWindow = (value: unknown): Record | null => { if (!value || typeof value !== 'object') return null const windowValue = (value as Record).window if (!windowValue || typeof windowValue !== 'object') return null return windowValue as Record } const snapshot = await window.__sootsimTest?.getSurfaceMetricsSnapshot?.() const windowMetrics = metricsWindow(snapshot) const width = windowMetrics?.width const height = windowMetrics?.height if ( typeof width === 'number' && typeof height === 'number' && width > 0 && height > 0 ) { return { ready: true } } return { ready: false, snapshot } }) if ( lastSnapshot && typeof lastSnapshot === 'object' && (lastSnapshot as { ready?: boolean }).ready ) { return } await page.waitForTimeout(50) } throw new Error( `timed out waiting for sootsim surface metrics: ${JSON.stringify(lastSnapshot)}`, ) } async function waitForSootsimShellReady(page: Page, timeout = 30000): Promise { try { await page.evaluate(async (timeoutMs) => { type ShellHostReadyBridge = { ready?: Promise firstContentPainted?: Promise } const start = Date.now() const deadline = start + timeoutMs const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const remaining = () => Math.max(1, deadline - Date.now()) const withTimeout = async (label: string, promise: Promise) => { let timer: ReturnType | null = null try { await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout( () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), remaining(), ) }), ]) } finally { if (timer) clearTimeout(timer) } } const isTenantOnly = new URL(window.location.href).searchParams.get('renderMode') === 'tenant-only' const currentUrl = new URL(window.location.href) const expectsInitialAppPaint = currentUrl.searchParams.has('test') || currentUrl.pathname.includes('/app/') let shellHost: ShellHostReadyBridge | null = null while (Date.now() < deadline) { const candidate = (window as any).SootSim?.bridges?.shellHost if (candidate || isTenantOnly) { shellHost = candidate ?? null break } await sleep(25) } if (!shellHost) { if (isTenantOnly) return throw new Error(`sootsim shell host unavailable after ${timeoutMs}ms`) } if (shellHost.ready && typeof shellHost.ready.then === 'function') { await withTimeout('sootsim shell ready', shellHost.ready) } if ( !expectsInitialAppPaint && shellHost.firstContentPainted && typeof shellHost.firstContentPainted.then === 'function' ) { await withTimeout( 'sootsim shell first content paint', shellHost.firstContentPainted, ) } const waitForScreenTransitions = (window as any).__sootsimTest ?.waitForScreenTransitions if (typeof waitForScreenTransitions === 'function') { const transitionResult = await waitForScreenTransitions({ timeoutMs: Math.min(remaining(), 3000), settleMs: 64, startWindowMs: 700, }) if (transitionResult?.timedOut === true) { throw new Error( `sootsim app launch transitions timed out: ${JSON.stringify(transitionResult)}`, ) } } }, timeout) } catch (error) { throw await describeSootsimBridgeFailure(page, error) } } async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise { const result = await page.evaluate( async ({ timeout }) => { const getPeerStats = (window as any).__sootsimTest?.getPeerStats if (typeof getPeerStats !== 'function') { return { settled: true, skipped: true } } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const readStats = () => Promise.race([ getPeerStats(false), new Promise((resolve) => setTimeout(() => resolve(null), 250)), ]) const start = Date.now() const deadline = start + timeout const requiredStableMs = 120 let stableSince: number | null = null let lastPending = 0 await sleep(0) while (Date.now() < deadline) { const stats = await readStats() const pending = stats && typeof stats === 'object' && typeof (stats as { pendingRunWorklets?: unknown }).pendingRunWorklets === 'number' ? stats.pendingRunWorklets : 0 lastPending = pending if (pending === 0) { stableSince ??= Date.now() if (Date.now() - stableSince >= requiredStableMs) { return { settled: true, elapsed: Date.now() - start, pending } } } else { stableSince = null } await sleep(25) } return { settled: false, elapsed: Date.now() - start, pending: lastPending, } }, { timeout }, ) if (!result?.settled) { throw new Error( `sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${result?.pending ?? 'unknown'})`, ) } } function reapOwnedBrowser(): void { const reap = _reapBrowserNow _reapBrowserNow = null _browser = null try { reap?.() } catch {} } async function closeContext() { if (_browserCompromised) { throw new Error(`sootsim browser is compromised: ${_browserCompromised}`) } if (!_context) return const context = _context const closingContextId = contextIdentity try { await withLifecycleTimeout('context.close()', closingContextId, () => context.close()) _context = null _page = null contextIdentity = 0 _synchronizationEnabled = false } catch (error) { _browserCompromised = error instanceof Error ? error.message : String(error) reapOwnedBrowser() throw error } } async function closeBrowser() { if (!_browser) return const browser = _browser const reapBrowserNow = _reapBrowserNow _browser = null _reapBrowserNow = null browserIdentity = 0 let closeError: Error | null = null try { await closeContext() } catch (error) { closeError = error instanceof Error ? error : new Error(String(error)) } // reap Chrome directly without waiting on an unbounded browser.close() try { reapBrowserNow?.() } catch {} try { await browser.close() } catch {} if (closeError) { throw closeError } } export async function waitForSootsimIdle( page: Page, maxMs = 3000, strict = false, ): Promise { const result = await page.evaluate( async ({ maxMs, strict }) => { const start = Date.now() let transitionError: string | null = null try { const waitForScreenTransitions = (window as any).__sootsimTest ?.waitForScreenTransitions if (typeof waitForScreenTransitions === 'function') { const transitionResult = await waitForScreenTransitions({ timeoutMs: Math.min(maxMs, 1800), settleMs: 48, startWindowMs: 600, }) if (transitionResult?.timedOut) { return { settled: false, elapsed: transitionResult.waitedMs, reason: 'screen transition', } } } } catch (error) { transitionError = error instanceof Error ? error.message : String(error) } const deadline = start + maxMs const pollMs = 50 const requiredStablePolls = 3 const layoutTolerance = 1 const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const isLayoutStable = (a: number[] | null, b: number[]) => { if (!a || a.length !== b.length) return false for (let i = 0; i < b.length; i++) { if (Math.abs(a[i] - b[i]) > layoutTolerance) return false } return true } const readSnapshot = async () => { const root = (window as any).__sootsimRoot const layout: number[] = [] let animating = false let auxSurfaceDebug: unknown = null let layoutDirty = false let renderStatsAvailable = false if (strict) { try { const renderHost: unknown = Reflect.get(window, '__sootsimRenderHost') const queryStats = typeof renderHost === 'object' && renderHost !== null ? Reflect.get(renderHost, 'queryStats') : null const stats = typeof queryStats === 'function' ? await queryStats.call(renderHost) : null renderStatsAvailable = !!stats auxSurfaceDebug = stats?.auxSurfaceDebug ?? null animating = stats?.hasActiveAnims === true || stats?.hasActiveNativeAnimations === true || stats?.hasPendingAnimationFrames === true layoutDirty = stats?.layoutDirty === true && stats?.renderRequested === true } catch {} } if (root) { const walk = (node: any) => { if (node.layout && node.layout.width > 0) { layout.push( Math.round(node.layout.x), Math.round(node.layout.y), Math.round(node.layout.width), Math.round(node.layout.height), ) } for (const child of node.children || []) walk(child) } walk(root) } return { animating, auxSurfaceDebug, layout, layoutDirty, renderStatsAvailable, } } let stableLayout: number[] | null = null let stable = 0 let lastSnapshot: Awaited> | null = null while (Date.now() < deadline) { const snapshot = await readSnapshot() lastSnapshot = snapshot if ( (!strict || snapshot.renderStatsAvailable) && !snapshot.animating && !snapshot.layoutDirty && isLayoutStable(stableLayout, snapshot.layout) ) { stable++ if (stable >= requiredStablePolls) { return { settled: true, elapsed: Date.now() - start } } } else { stableLayout = snapshot.layout stable = 0 } await sleep(pollMs) } return { settled: false, elapsed: Date.now() - start, reason: transitionError ? `layout fallback after screen transition wait failed: ${transitionError}` : strict ? `strict idle state unavailable: renderStats=${lastSnapshot?.renderStatsAvailable === true} animating=${lastSnapshot?.animating === true} layoutDirty=${lastSnapshot?.layoutDirty === true} layoutNodes=${(lastSnapshot?.layout.length ?? 0) / 4} aux=${JSON.stringify(lastSnapshot?.auxSurfaceDebug)}` : 'layout fallback', } }, { maxMs, strict }, ) if (!result?.settled) { throw new Error( `sootsim synchronization timed out after ${result?.elapsed ?? maxMs}ms${result?.reason ? ` (${result.reason})` : ''}`, ) } } // find a node in the sootsim tree matching a matcher descriptor async function findNodeByMatcher(matcher: Matcher): Promise { const page = getPage() if (matcher.index !== undefined) { return page.evaluate(async (indexedMatcher: Matcher) => { const filter = indexedMatcher.type === 'id' ? { hasId: indexedMatcher.value } : indexedMatcher.type === 'text' ? { hasText: indexedMatcher.value } : indexedMatcher.type === 'label' ? { hasLabel: indexedMatcher.value } : indexedMatcher.type === 'role' ? { hasRole: indexedMatcher.value } : { type: indexedMatcher.value } const results = await window.__sootsimTest!.queryAll(filter) // detox indexes by.text over the text elements that read exactly that // text. hasText also matches every ancestor, so index 0 would be the root. const indexed = indexedMatcher.type === 'text' ? results.filter( (node) => node.type === 'text' && node.text === indexedMatcher.value, ) : results return indexed[indexedMatcher.index ?? 0] ?? null }, matcher) } if (matcher.type === 'id') { return page.evaluate( (id: string) => window.__sootsimTest!.findByTestId(id), matcher.value, ) } else if (matcher.type === 'text') { return page.evaluate( (text: string) => window.__sootsimTest!.findByText(text), matcher.value, ) } else if (matcher.type === 'label') { return page.evaluate( (label: string) => window.__sootsimTest!.findByLabel(label), matcher.value, ) } else if (matcher.type === 'role') { return page.evaluate( (role: string) => window.__sootsimTest!.findByRole(role), matcher.value, ) } else if (matcher.type === 'type') { return page.evaluate(async (type: string) => { const results = await window.__sootsimTest!.queryAll({ type }) return results[0] || null }, matcher.value) } return null } // get the absolute center coordinates of a node in sootsim coordinate space function getNodeCenter(nodeInfo: any): { x: number; y: number } { return { x: nodeInfo.absolutePosition.x + nodeInfo.layout.width / 2, y: nodeInfo.absolutePosition.y + nodeInfo.layout.height / 2, } } async function nodeOffsetToPage( page: Page, nodeInfo: any, dx: number, dy: number, ): Promise<{ x: number; y: number }> { const center = getNodeCenter(nodeInfo) return sootsimToPage(page, center.x + dx, center.y + dy) } async function getDefaultTapPoint( page: Page, nodeInfo: any, ): Promise<{ x: number; y: number }> { const resolvedTarget = await page.evaluate( (nodeId) => window.__sootsimTest?.resolveTapTarget?.(nodeId) ?? null, nodeInfo.nodeId, ) const center = resolvedTarget && typeof resolvedTarget.cx === 'number' && typeof resolvedTarget.cy === 'number' ? { x: resolvedTarget.cx, y: resolvedTarget.cy } : getNodeCenter(nodeInfo) const frame = nodeInfo.visibleFrame ?? { x: nodeInfo.absolutePosition?.x ?? nodeInfo.layout?.x ?? 0, y: nodeInfo.absolutePosition?.y ?? nodeInfo.layout?.y ?? 0, width: nodeInfo.layout?.width ?? 0, height: nodeInfo.layout?.height ?? 0, } const viewport = await readSootsimInteractiveViewport(page) const fullFrameTolerance = 0.5 // the resolved center may belong to a descendant of the matched frame. const canTapCenter = typeof nodeInfo.absolutePosition?.x === 'number' && typeof nodeInfo.absolutePosition?.y === 'number' && typeof nodeInfo.layout?.width === 'number' && typeof nodeInfo.layout?.height === 'number' && Math.abs(frame.x - nodeInfo.absolutePosition.x) <= fullFrameTolerance && Math.abs(frame.y - nodeInfo.absolutePosition.y) <= fullFrameTolerance && frame.width >= nodeInfo.layout.width - fullFrameTolerance && frame.height >= nodeInfo.layout.height - fullFrameTolerance && center.x >= 0 && center.y >= 0 && center.x < viewport.width && center.y < viewport.height if (canTapCenter) return center const left = Math.max(0, frame.x) const top = Math.max(0, frame.y) const right = Math.min(viewport.width, frame.x + frame.width) const bottom = Math.min(viewport.height, frame.y + frame.height) if (right <= left || bottom <= top) { return center } return { x: left + (right - left) / 2, y: top + (bottom - top) / 2, } } async function dispatchTapAtSootsimPoint(page: Page, x: number, y: number) { await page.evaluate( async ({ x, y }) => { const interactTap = window.SootSim?.bridges?.interact?.tap if (typeof interactTap === 'function') { const result = await interactTap(x, y) if (result && (typeof result !== 'object' || result.hit !== false)) return } const tap = window.__sootsimTest?.tap if (typeof tap !== 'function') { throw new Error('sootsim tap bridge is not installed') } await tap(x, y) }, { x, y }, ) } async function isFocusedTextInputNode(page: Page, nodeInfo: any): Promise { const focused = await page.evaluate( () => window.__sootsimTest!.getFocusedNode?.() ?? null, ) return !!focused && typeof focused === 'object' && focused.nodeId === nodeInfo?.nodeId } async function focusElementForTextEntry(el: SootSimElement): Promise { const page = getPage() const node = await findNodeByMatcher(el._matcher) if (!node) { throw new Error(`element not found for text entry: ${JSON.stringify(el._matcher)}`) } if (await isFocusedTextInputNode(page, node)) return await el.tap() await page.waitForTimeout(100) } function ensureScreenshotDir() { if (!fs.existsSync(SCREENSHOT_DIR)) { fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }) } } function decodePngDataUrl(dataUrl: string): Buffer { const match = /^data:image\/png;base64,(.+)$/.exec(dataUrl) if (!match) { throw new Error( `rnx screenshot bridge returned a non-png payload: ${JSON.stringify(dataUrl.slice(0, 120))}`, ) } return Buffer.from(match[1], 'base64') } type FastScreenshotData = { label: string offsetMs: number actualMs: number captureRequestedAtMs?: number dataUrl: string frameIndex?: number virtualTimestampMs?: number } function writeFastScreenshotFrames( namePrefix: string, frames: T[], ): Array & { path: string }> { ensureScreenshotDir() return frames.map((frame) => { const safeName = `${namePrefix}-${frame.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-') const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`) fs.writeFileSync(filePath, decodePngDataUrl(frame.dataUrl)) const { dataUrl: _dataUrl, ...metadata } = frame return { ...metadata, path: filePath, } }) } // single-shot canvas capture — no frame-stability poll. used for multi-stage // animation captures where we explicitly want to sample mid-transition. the // usual captureSootsimPng requires three consecutive idle frames to be byte-equal // which is ideal for settled screenshots but blocks for the full timeout // while an animation is running. async function captureSootsimPngFast(opts?: { crop?: { h: number; w: number; x: number; y: number } }) { const page = getPage() const dataUrl = await page.evaluate( async (captureOpts) => { const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } }) .SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise) | undefined if (typeof screenshot !== 'function') { throw new Error('rnx screenshot bridge is not installed') } return screenshot(captureOpts) }, { crop: opts?.crop, format: 'png' }, ) if (!dataUrl) { throw new Error('rnx screenshot bridge returned an empty image') } return decodePngDataUrl(dataUrl) } async function setSootsimCursorCaptureActive(page: Page, active: boolean) { await page.evaluate(async (captureActive) => { const bridge = window.__sootsimTest if (typeof bridge?.setCursorCaptureActive !== 'function') { throw new Error('sootsim cursor capture control is not installed') } await bridge.setCursorCaptureActive(captureActive) }, active) } async function captureSootsimPng(opts?: { crop?: { h: number; w: number; x: number; y: number } }) { const page = getPage() // a focused TextInput caret changes on a timer. pin it visible for the // capture transaction so forced frames represent one settled visual state. await setSootsimCursorCaptureActive(page, true) try { const deadline = Date.now() + 8000 let previous: Buffer | null = null let identicalFrames = 1 let attempts = 0 while (Date.now() < deadline) { await waitForSootsimIdle(page, Math.max(1, deadline - Date.now()), true) const current = await captureSootsimPngFast(opts) attempts++ if (framesIdentical(previous, current)) { identicalFrames++ if (identicalFrames >= REQUIRED_IDENTICAL_PROOF_FRAMES) return current } else { identicalFrames = 1 } previous = current } throw new Error( `rnx screenshot did not reach ${REQUIRED_IDENTICAL_PROOF_FRAMES} identical idle frames in ${attempts} captures`, ) } finally { await setSootsimCursorCaptureActive(page, false) } } async function writeSootsimScreenshot( name: string, opts?: { crop?: { h: number; w: number; x: number; y: number } }, ) { ensureScreenshotDir() const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`) fs.writeFileSync(screenshotPath, await captureSootsimPng(opts)) return screenshotPath } async function dispatchStatusBarOverride(config: StatusBarConfig) { const page = getPage() const time = typeof config.time === 'string' && config.time.length > 0 ? config.time : null await page.evaluate( ({ eventType, time }) => { window.dispatchEvent( new CustomEvent(eventType, { detail: { time }, }), ) }, { eventType: STATUS_BAR_OVERRIDE_EVENT, time }, ) await page.waitForTimeout(50) } const MAX_SETTLED_SPEED_PT_PER_SEC = 2.0 async function waitForFrameClock(page: Page, minMs = 25): Promise { const start = performance.now() await page.evaluate( () => new Promise((resolve) => { const timeoutId = setTimeout(() => resolve(), 50) requestAnimationFrame(() => { clearTimeout(timeoutId) resolve() }) }), ) const elapsed = performance.now() - start if (elapsed < minMs) { await page.waitForTimeout(minMs - elapsed) } } async function findStableNodeByMatcher( matcher: Matcher, action = 'tap', maxWaitMs = 1500, ): Promise { const page = getPage() const start = performance.now() let lastPos: { x: number; y: number } | null = null let lastTime: number | null = null let lastRate: { vx: number; vy: number; speed: number } | null = null let sampleCount = 0 let stablePolls = 0 const requiredStable = 2 let hadNumericLayout = false let disappearedMidWait = false while (performance.now() - start < maxWaitMs) { const node = await findNodeByMatcher(matcher) if (!node) { if (hadNumericLayout) { disappearedMidWait = true } else { return null } } else { disappearedMidWait = false const pos = node.absolutePosition ?? node.layout if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') { hadNumericLayout = true sampleCount++ const now = performance.now() if (lastPos && lastTime !== null) { const dtSec = (now - lastTime) / 1000 if (dtSec > 0.005) { const vx = Math.abs(pos.x - lastPos.x) / dtSec const vy = Math.abs(pos.y - lastPos.y) / dtSec const speed = Math.sqrt(vx * vx + vy * vy) lastRate = { vx, vy, speed } if (speed < MAX_SETTLED_SPEED_PT_PER_SEC) { stablePolls++ if (stablePolls >= requiredStable) { return node } } else { stablePolls = 0 } } } lastPos = { x: pos.x, y: pos.y } lastTime = now } } await waitForFrameClock(page, 25) } const elapsed = Math.round(performance.now() - start) if (disappearedMidWait) { throw new Error( `element disappeared while waiting to come to rest for ${action}: ${JSON.stringify(matcher)} after ${elapsed}ms`, ) } if (!hadNumericLayout) { throw new Error( `element layout is not available for ${action}: ${JSON.stringify(matcher)} after ${elapsed}ms`, ) } if (sampleCount < 2 || lastRate === null) { throw new Error( `element did not come to rest for ${action}: ${JSON.stringify(matcher)}; only one sample obtained after ${elapsed}ms`, ) } throw new Error( `element did not come to rest for ${action}: ${JSON.stringify(matcher)}; still moving ${lastRate.vx.toFixed(1)} points/sec x / ${lastRate.vy.toFixed(1)} points/sec y after ${elapsed}ms`, ) } function createSootSimElement(matcher: Matcher): SootSimElement { const el: SootSimElement = { _matcher: matcher, async tap(point) { const page = getPage() await waitForSootsimWorkletsIdle(page, 3000) const node = await findStableNodeByMatcher(matcher, 'tap') if (!node) throw new Error(`element not found for tap: ${JSON.stringify(matcher)}`) if (!isNodeVisible(node)) { throw new Error( `Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`, ) } const defaultPoint = point ? null : await getDefaultTapPoint(page, node) const targetSootSimX = typeof point?.x === 'number' ? node.absolutePosition.x + point.x : (defaultPoint?.x ?? node.absolutePosition.x + node.layout.width / 2) const targetSootSimY = typeof point?.y === 'number' ? node.absolutePosition.y + point.y : (defaultPoint?.y ?? node.absolutePosition.y + node.layout.height / 2) await dispatchTapAtSootsimPoint(page, targetSootSimX, targetSootSimY) if (_synchronizationEnabled) { await waitForSootsimIdle(page) } }, async multiTap(times: number) { // detox iOS multiTap performs N taps with the system's natural double- // tap pause. UITapGestureRecognizer treats taps within ~250ms as a // multi-tap. emit each tap as a discrete down/up at the same node // center with a small inter-tap pause that stays inside RNGH's // default maxDelay window of 200ms. const page = getPage() const node = await findStableNodeByMatcher(matcher, 'multiTap') if (!node) throw new Error(`element not found for multiTap: ${JSON.stringify(matcher)}`) if (!isNodeVisible(node)) { throw new Error( `Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`, ) } const center = getNodeCenter(node) const pageCoords = await sootsimToPage(page, center.x, center.y) for (let i = 0; i < times; i++) { await page.mouse.move(pageCoords.x, pageCoords.y) await page.mouse.down() await page.waitForTimeout(50) await page.mouse.up() if (i < times - 1) { await page.waitForTimeout(80) } } await page.waitForTimeout(80) }, // mirrors detox element.longPress(duration) and longPress(point, duration), // where the point is relative to the element's origin async longPress( pointOrDuration?: { x: number; y: number } | number, durationArg = 1000, ) { const page = getPage() const node = await findStableNodeByMatcher(matcher, 'longPress') if (!node) throw new Error(`element not found for longPress: ${JSON.stringify(matcher)}`) if (!isNodeVisible(node)) { throw new Error( `Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`, ) } const duration = typeof pointOrDuration === 'number' ? pointOrDuration : durationArg const target = typeof pointOrDuration === 'object' ? { x: node.absolutePosition.x + pointOrDuration.x, y: node.absolutePosition.y + pointOrDuration.y, } : getNodeCenter(node) await page.evaluate( async ({ duration, x, y }) => { const longPress = window.SootSim?.bridges?.interact?.longPress if (typeof longPress !== 'function') { throw new Error('sootsim longPress bridge is not installed') } const hit = await longPress(x, y, duration) if (!hit) { throw new Error(`sootsim longPress missed at ${x},${y}`) } }, { duration, x: target.x, y: target.y }, ) await page.waitForTimeout(50) }, async longPressAndDrag( duration: number, normalizedStartX: number, normalizedStartY: number, targetElement: SootSimElement, normalizedEndX: number, normalizedEndY: number, speed = 'fast', holdDuration = 0, ) { const page = getPage() // resolve source element const srcNode = await findStableNodeByMatcher(matcher, 'longPressAndDrag') if (!srcNode) throw new Error(`source element not found: ${JSON.stringify(matcher)}`) // resolve target element const tgtNode = await findStableNodeByMatcher( targetElement._matcher, 'longPressAndDrag', ) if (!tgtNode) throw new Error( `target element not found: ${JSON.stringify(targetElement._matcher)}`, ) // compute start and end in sootsim coords const startSootSimX = srcNode.absolutePosition.x + srcNode.layout.width * normalizedStartX const startSootSimY = srcNode.absolutePosition.y + srcNode.layout.height * normalizedStartY const endSootSimX = tgtNode.absolutePosition.x + tgtNode.layout.width * normalizedEndX const endSootSimY = tgtNode.absolutePosition.y + tgtNode.layout.height * normalizedEndY const startPage = await sootsimToPage(page, startSootSimX, startSootSimY) const endPage = await sootsimToPage(page, endSootSimX, endSootSimY) const steps = speed === 'slow' ? 20 : 10 // press at start position await page.mouse.move(startPage.x, startPage.y) await page.mouse.down() await page.waitForTimeout(Math.max(duration, holdDuration)) // drag to end position 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(speed === 'slow' ? 30 : 10) } await page.mouse.up() await page.waitForTimeout(50) }, async typeText(text: string) { const page = getPage() await focusElementForTextEntry(el) await page.keyboard.type(text) }, async replaceText(text: string) { const page = getPage() const node = await findNodeByMatcher(matcher) if ( matcher.type === 'id' && typeof node?.nodeId === 'number' && node.nodeId >= 0x40000000 ) { const replaced = await page.evaluate( async ({ id, value }) => { const mainShell = window.SootSim?.bridges?.mainShell if (typeof mainShell?.callTestBridge !== 'function') return false return mainShell.callTestBridge('replaceTextByTestId', id, value) }, { id: matcher.value, value: text }, ) if (replaced === true) return } await focusElementForTextEntry(el) const value = typeof node?.text === 'string' ? node.text : '' for (let i = 0; i < value.length; i++) { await page.keyboard.press('Backspace') } await page.keyboard.type(text) }, async clearText() { const page = getPage() await focusElementForTextEntry(el) const node = await findNodeByMatcher(matcher) const value = typeof node?.text === 'string' ? node.text : '' for (let i = 0; i < value.length; i++) { await page.keyboard.press('Backspace') } }, async scroll( pixels: number, direction: 'up' | 'down' | 'left' | 'right', startPositionX?: number, startPositionY?: number, ) { const node = await findNodeByMatcher(matcher) if (!node) throw new Error(`element not found for scroll: ${JSON.stringify(matcher)}`) const page = getPage() await dragScrollNode(page, node, pixels, direction, startPositionX, startPositionY) }, async scrollTo( edge: 'top' | 'bottom' | 'left' | 'right', startPositionX?: number, startPositionY?: number, ) { const node = await findNodeByMatcher(matcher) if (!node) throw new Error(`element not found for scrollTo: ${JSON.stringify(matcher)}`) const targetId = typeof node.testID === 'string' && node.testID ? node.testID : typeof node.id === 'string' && node.id ? node.id : null if (!targetId) { throw new Error( `scrollTo requires an id-backed scroll view: ${JSON.stringify(matcher)}`, ) } const normalizedStartX = resolveScrollStartPosition( startPositionX, 'scrollTo startPositionX', ) const normalizedStartY = resolveScrollStartPosition( startPositionY, 'scrollTo startPositionY', ) const hitX = node.absolutePosition.x + node.layout.width * normalizedStartX const hitY = node.absolutePosition.y + node.layout.height * normalizedStartY const page = getPage() const result = await page.evaluate( async ({ edge, hitX, hitY, targetId }) => { const bridge = window.__sootsimTest if (!bridge?.getScrollStateAt || !bridge.scrollTo) { return { ok: false, reason: 'scroll bridge unavailable' } } const state = await bridge.getScrollStateAt(hitX, hitY) if (!state) return { ok: false, reason: 'scroll state not found' } const maxOffset = state.maxOffset && typeof state.maxOffset === 'object' ? (state.maxOffset as { x?: number; y?: number }) : null const offset = state.offset && typeof state.offset === 'object' ? (state.offset as { x?: number; y?: number }) : null const maxX = typeof state.maxOffsetX === 'number' ? state.maxOffsetX : (maxOffset?.x ?? 0) const maxY = typeof state.maxOffsetY === 'number' ? state.maxOffsetY : (maxOffset?.y ?? 0) const currentX = typeof state.offsetX === 'number' ? state.offsetX : (offset?.x ?? 0) const currentY = typeof state.offsetY === 'number' ? state.offsetY : (offset?.y ?? 0) const x = edge === 'left' ? 0 : edge === 'right' ? maxX : currentX const y = edge === 'top' ? 0 : edge === 'bottom' ? maxY : currentY return bridge.scrollTo(targetId, x, y, false) }, { edge, hitX, hitY, targetId }, ) if (!result?.ok) { throw new Error( `scrollTo(${edge}) failed for ${targetId}: ${result?.reason ?? 'unknown error'}`, ) } await page.evaluate( () => new Promise((resolve) => { requestAnimationFrame(() => resolve()) }), ) if (_synchronizationEnabled) { await waitForSootsimIdle(page) } }, async swipe( direction: 'up' | 'down' | 'left' | 'right', speed: 'fast' | 'slow' = 'fast', percentage = 0.75, normalizedStartingPointX = 0.5, normalizedStartingPointY = 0.5, ) { const node = await findStableNodeByMatcher(matcher, 'swipe') if (!node) throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`) const page = getPage() const { absolutePosition, layout } = node const startSootSimX = absolutePosition.x + layout.width * normalizedStartingPointX const startSootSimY = absolutePosition.y + layout.height * normalizedStartingPointY let endSootSimX = startSootSimX let endSootSimY = startSootSimY const viewport = await readSootsimViewport(page) const dist = direction === 'up' || direction === 'down' ? viewport.height * percentage : viewport.width * percentage switch (direction) { case 'up': endSootSimY -= dist break case 'down': endSootSimY += dist break case 'left': endSootSimX -= dist break case 'right': endSootSimX += dist break } // detox injects one point per display-link tick. its fast path contains // 21 points and its slow path 41, including the endpoints. const steps = speed === 'slow' ? 40 : 20 const stepDelay = 1000 / 60 await page.evaluate( async ({ fromX, fromY, toX, toY, steps, stepMs }) => { // the interact bridge, not __sootsimTest.drag. the test bridge's drag // runs the whole down/move/up loop through the tenant's own // dispatchPointer and never fans to the shell, where gesture-handler's // recognizers live, so a swipe reported exact-zero activations for // every pan, fling, and manual gesture. taps were unaffected because // the responder path is tenant-local. the interact bridge resolves the // owner and feeds both, which is why the cases that call it directly // pass. const drag = window.__sootsimInteract?.drag if (typeof drag !== 'function') { throw new Error('sootsim interact bridge is not installed') } await drag(fromX, fromY, toX, toY, steps, stepMs) }, { fromX: startSootSimX, fromY: startSootSimY, toX: endSootSimX, toY: endSootSimY, steps, stepMs: stepDelay, }, ) await page.waitForTimeout(100) }, async setColumnToValue(column: number, value: string) { if (!Number.isInteger(column) || column < 0) { throw new Error(`picker column must be a non-negative integer, got ${column}`) } if (typeof value !== 'string') { throw new Error(`picker value must be a string, got ${typeof value}`) } const picker = await findStableNodeByMatcher(matcher, 'setColumnToValue') if (!picker) { throw new Error( `element not found for setColumnToValue: ${JSON.stringify(matcher)}`, ) } const page = getPage() const candidates = await page.evaluate( async ({ picker, column, value }) => { const result = await window.__sootsimTest?.queryTextCandidates?.({ query: value, exact: true, }) if (!result || !Array.isArray(result.candidates)) return [] const left = picker.absolutePosition.x const top = picker.absolutePosition.y const right = left + picker.layout.width const bottom = top + picker.layout.height return result.candidates .filter((candidate) => candidate.ancestorPickerColumnIndices.includes(column)) .map((candidate) => candidate.info) .filter((candidate) => { const x = candidate.absolutePosition?.x const y = candidate.absolutePosition?.y const width = candidate.layout?.width const height = candidate.layout?.height if ( typeof x !== 'number' || typeof y !== 'number' || typeof width !== 'number' || typeof height !== 'number' ) { return false } const centerX = x + width / 2 const centerY = y + height / 2 return ( centerX >= left && centerX <= right && centerY >= top && centerY <= bottom ) }) }, { picker, column, value }, ) if (candidates.length !== 1) { throw new Error( `setColumnToValue(${column}, ${JSON.stringify(value)}) found ${candidates.length} matching rows inside ${JSON.stringify(matcher)}`, ) } const target = candidates[0] // detox selects the picker row, whose hit rectangle stays flat. the // label is visually projected around the wheel and can overlap a later // label near the cylinder edge. const tapTarget = await page.evaluate( async (nodeId) => window.__sootsimTest?.resolveTapTarget(nodeId), target.nodeId, ) if (!tapTarget) { throw new Error( `setColumnToValue(${column}, ${JSON.stringify(value)}) could not resolve its picker row`, ) } await dispatchTapAtSootsimPoint( page, tapTarget.target.absolutePosition.x + tapTarget.target.layout.width / 2, tapTarget.target.absolutePosition.y + tapTarget.target.layout.height / 2, ) }, async pinch(scale: number, speed: 'fast' | 'slow' = 'fast', angle: number = 0) { // detox iOS pinch: scale > 1 spreads fingers (zoom in), scale < 1 // brings them together (zoom out). speed maps to step delay so RNGH's // velocity tracking sees a realistic delta. angle (radians) tilts the // pinch axis off horizontal — matches Detox's third arg. const page = getPage() const node = await findStableNodeByMatcher(matcher, 'pinch') if (!node) throw new Error(`element not found for pinch: ${JSON.stringify(matcher)}`) if (!node.nativePinchEnabled) { await waitForSootsimGestureHandler(page, node.nodeId, 'PinchGestureHandler') } const startSpread = Math.min( 60, Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25), ) const endSpread = startSpread * Math.max(scale, 0.05) const cos = Math.cos(angle) const sin = Math.sin(angle) const offset = (d: number) => ({ dx: d * cos, dy: d * sin }) const a0 = offset(-startSpread) const b0 = offset(startSpread) const a1 = offset(-endSpread) const b1 = offset(endSpread) await dispatchTwoFingerGesture( page, await nodeOffsetToPage(page, node, a0.dx, a0.dy), await nodeOffsetToPage(page, node, b0.dx, b0.dy), await nodeOffsetToPage(page, node, a1.dx, a1.dy), await nodeOffsetToPage(page, node, b1.dx, b1.dy), { steps: speed === 'fast' ? 4 : 12, stepDelayMs: speed === 'fast' ? 18 : 24 }, ) }, async rotate(radians: number, speed: 'fast' | 'slow' = 'fast') { // two fingers held on a horizontal axis around the node center, then // both rotated to `radians` around the same center. positive radians // rotate counter-clockwise to match RNGH RotationGesture's reported // sign (its 'rotation' delta is positive for a counter-clockwise // motion of the rear finger relative to the front finger). const page = getPage() const node = await findStableNodeByMatcher(matcher, 'rotate') if (!node) throw new Error(`element not found for rotate: ${JSON.stringify(matcher)}`) await waitForSootsimGestureHandler(page, node.nodeId, 'RotationGestureHandler') const radius = Math.min( 60, Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25), ) const startA = await nodeOffsetToPage(page, node, -radius, 0) const startB = await nodeOffsetToPage(page, node, radius, 0) const cos = Math.cos(radians) const sin = Math.sin(radians) const endA = await nodeOffsetToPage(page, node, -radius * cos, -radius * sin) const endB = await nodeOffsetToPage(page, node, radius * cos, radius * sin) await dispatchTwoFingerGesture(page, startA, startB, endA, endB, { steps: speed === 'fast' ? 10 : 20, stepDelayMs: speed === 'fast' ? 14 : 24, }) }, async takeScreenshot(name: string): Promise { const node = await findNodeByMatcher(matcher) if (!node) throw new Error( `element not found for takeScreenshot: ${JSON.stringify(matcher)}`, ) const box = node.frame ?? { x: node.absolutePosition.x, y: node.absolutePosition.y, width: node.layout.width, height: node.layout.height, } return writeSootsimScreenshot(name, { crop: { x: box.x, y: box.y, w: box.width, h: box.height, }, }) }, async getAttributes(): Promise { const node = await findNodeByMatcher(matcher) if (!node) throw new Error(`element not found for getAttributes: ${JSON.stringify(matcher)}`) return { text: node.text || '', label: node.accessibilityLabel || node.text || '', identifier: node.testID || node.id || '', visible: node.layout.width > 0 && node.layout.height > 0, enabled: !node.accessibilityState?.disabled, ...node, } }, atIndex(index: number) { return createSootSimElement({ ...matcher, index }) }, } return el } // element() factory -- takes a matcher and returns an element interaction object export function element(matcher: Matcher): SootSimElement { return createSootSimElement(matcher) } // device object -- app lifecycle export const device = { _currentUrl: '', _platform: DETOX_PLATFORM as 'ios' | 'android', async launchApp(opts?: { delete?: boolean newInstance?: boolean url?: string launchArgs?: Record // when set, the new BrowserContext records video at this dir. used by // captureProofMenuStages for the multi-stage animation diff path. must // be set at launchApp time because playwright recordVideo can't be // toggled on an existing context. recordVideoDir?: string }) { if (_browserCompromised) { throw new Error(`sootsim browser is compromised: ${_browserCompromised}`) } if ( _page && opts?.newInstance === false && !opts.delete && !opts.recordVideoDir && _suspendedAppId ) { const page = _page const appId = _suspendedAppId await page.evaluate( (id) => window.SootSim?.bridges?.mainShell?.launchApp?.(id), appId, ) await pollUntil( () => page.evaluate(async (id) => { const state = await window.SootSim?.bridges?.mainShell?.getState?.() return ( state?.state === 'app' && state.activeApp === id && state.switcherPhase === 'idle' ) }, appId), Boolean, { timeoutMs: 10000, label: `SootSim app ${appId} to resume`, }, ) _suspendedAppId = null await waitForSootsimTree(page, 10000) return } if (!_browser) { // Conformance renders CanvasKit and must use the real platform GPU. The // bundled chromium_headless_shell has no GPU and silently falls back to // SwiftShader, invalidating visual/perf results while pegging the box. // The canonical GPU args force Metal/Vulkan and stripping Playwright's // --enable-unsafe-swiftshader default makes GPU init fail loudly. The // scoped reaper owns the whole Chrome process tree if Detox is interrupted. const launched = await launchReapedChromeScoped( (options) => chromium.launch(options), { headless: true, ignoreDefaultArgs: ['--disable-popup-blocking'] }, ) _browser = launched.browser _reapBrowserNow = launched.reapNow browserIdentity = ++nextBrowserIdentity } if (!_page || opts?.newInstance || opts?.delete || opts?.recordVideoDir) { await closeContext() const contextOpts: Parameters['newContext']>[0] = { hasTouch: true, viewport: { width: 500, height: 900 }, } if (opts?.recordVideoDir) { fs.mkdirSync(opts.recordVideoDir, { recursive: true }) contextOpts.recordVideo = { dir: opts.recordVideoDir, size: { width: 500, height: 900 }, } } const newContextIdentity = ++nextContextIdentity try { _context = await withLifecycleTimeout( '_browser.newContext()', newContextIdentity, () => _browser!.newContext(contextOpts), ) } catch (error) { _browserCompromised = error instanceof Error ? error.message : String(error) reapOwnedBrowser() throw error } await _context.exposeBinding('__sootsimHostCapture', async (source, value) => { const request = readBrowserCompositeCaptureRequest(value) const session = await source.page.context().newCDPSession(source.page) try { const result = await session.send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: true, clip: resolveBrowserCompositeCdpClip( request, await session.send('Page.getLayoutMetrics'), ), }) return 'data:image/png;base64,' + result.data } finally { await session.detach() } }) try { _page = await withLifecycleTimeout('_context.newPage()', newContextIdentity, () => _context!.newPage(), ) } catch (error) { _browserCompromised = error instanceof Error ? error.message : String(error) reapOwnedBrowser() throw error } contextIdentity = newContextIdentity } const url = opts?.url || BASE_URL device._currentUrl = url await navigateSootsimPage(_page!, url, { browserId: browserIdentity, contextId: contextIdentity, operation: 'launchApp', }) await waitForSootsimTree(_page!, 30000) await waitForSootsimSurfaceMetrics(_page!, 30000) await waitForSootsimShellReady(_page!, 30000) await waitForSootsimWorkletsIdle(_page!, 3000) // record the wall-clock time the page is ready — captureProofMenuStages // uses this to compute trigger-in-video offsets device._lastReadyAtMs = Date.now() }, _lastReadyAtMs: 0, async reloadReactNative() { const page = getPage() await page.reload({ waitUntil: 'load', timeout: 30000 }) await waitForSootsimTree(page, 30000) await waitForSootsimSurfaceMetrics(page, 30000) await waitForSootsimShellReady(page, 30000) await waitForSootsimWorkletsIdle(page, 3000) }, async terminateApp() { await closeBrowser() }, async installApp() { // no-op for sootsim -- app runs in browser }, async uninstallApp() { // no-op }, async openURL(url: { url: string; sourceApp?: string }) { const page = getPage() await navigateSootsimPage(page, url.url, { browserId: browserIdentity, contextId: contextIdentity, operation: 'openURL', }) await waitForSootsimTree(page, 10000) await waitForSootsimSurfaceMetrics(page, 10000) await waitForSootsimShellReady(page, 10000) await waitForSootsimWorkletsIdle(page, 3000) }, async takeScreenshot(name: string): Promise { return writeSootsimScreenshot(name) }, // single-shot variant for multi-stage animation captures. skips the // frame-stability poll so callers can sample mid-transition. async takeScreenshotFast(name: string): Promise { ensureScreenshotDir() const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`) fs.writeFileSync(screenshotPath, await captureSootsimPngFast()) return screenshotPath }, // record the SootSim canvas via the engine's headless recorder // (window.__sootsimRecorder — @rnx/plugin-recording). this is the // SAME code path `rnx record video` drives, NOT the rail-button // path (SootSim.bridges.startRecording wires the dialog state machine // and never stashes the resulting blob into __sootsimRecorder.lastBlob, // which is what getBlobBase64 streams from). // // unlike playwright's page-level recordVideo, the headless recorder // captures ONLY the device canvas (composited shell + tenant surfaces) // — no browser chrome, no menu bar, no device frame. uses the EXISTING // page/context so per-test state is preserved across capture. async startBridgeRecording(opts?: { durationMs?: number layers?: 'full' | 'tenant' | 'shell' fps?: number }): Promise { const page = getPage() const startOpts = { format: 'webm' as const, fps: opts?.fps ?? 60, layers: opts?.layers ?? 'tenant', durationMs: opts?.durationMs ?? 5000, } const result = await page.evaluate(async (startOpts) => { const rec = ( window as { __sootsimRecorder?: { start?: ( o: unknown, ) => Promise<{ ok: boolean; error?: string; format?: string }> } } ).__sootsimRecorder const start = rec?.start if (typeof start !== 'function') { return { ok: false, error: '__sootsimRecorder.start not installed' } } return start(startOpts) }, startOpts) if (!result.ok) { throw new Error(`startBridgeRecording failed: ${result.error ?? 'unknown'}`) } }, // stop the headless recorder, stream the resulting blob in chunks (same // protocol as `rnx record`), and write the webm to outPath. async stopAndSaveBridgeRecording(outPath: string): Promise { const page = getPage() const stopResult = await page.evaluate(async () => { const rec = ( window as { __sootsimRecorder?: { stop?: () => Promise<{ ok: boolean error?: string size?: number mime?: string durationMs?: number }> } } ).__sootsimRecorder const stop = rec?.stop if (typeof stop !== 'function') { return { ok: false, error: '__sootsimRecorder.stop not installed' } } return stop() }) if (!stopResult.ok) { throw new Error(`stopBridgeRecording failed: ${stopResult.error ?? 'unknown'}`) } if (!stopResult.size) { throw new Error('stopBridgeRecording: recorder returned empty blob') } const chunks: Buffer[] = [] let offset = 0 const CHUNK = 2 * 1024 * 1024 while (true) { const result: { data: string size: number offset: number done: boolean mime: string } | null = await page.evaluate( ({ offset, chunk }) => { const rec = (window as { __sootsimRecorder?: { getBlobBase64?: unknown } }) .__sootsimRecorder const get = rec?.getBlobBase64 as | ((args: { offset: number; chunk: number }) => Promise<{ data: string size: number offset: number done: boolean mime: string } | null>) | undefined if (typeof get !== 'function') return null return get({ offset, chunk }) }, { offset, chunk: CHUNK }, ) if (!result) throw new Error('rnx recorder produced no blob') chunks.push(Buffer.from(result.data, 'base64')) offset = result.offset if (result.done) break } fs.mkdirSync(path.dirname(outPath), { recursive: true }) fs.writeFileSync(outPath, Buffer.concat(chunks)) ;(this as { _lastRecordingPath?: string })._lastRecordingPath = outPath return outPath }, _lastRecordingPath: '' as string, // park every render clock before the interaction, detect the first changed // stepped frame inside the named component, then advance exact 60fps frames // to each requested offset. native extraction also chooses the first frame // at or after an offset, so both sides use the same rounding convention. async takeScreenshotStagesFromVisibleChangeFast( namePrefix: string, stages: Array<{ label: string; offsetMs: number }>, options: { detectionTimeoutMs?: number frame?: { height: number; width: number; x: number; y: number } | null // a pointer the capture itself presses at the trigger point and releases // once durationMs of lockstep time has elapsed, so held states (a lifted // control thumb, a pressed row) are painted mid-hold. an external trigger // that holds and releases before returning never paints a frame in // between, because no lockstep frame steps while it runs. moves drag the // held pointer to each point once afterMs of lockstep time has elapsed // since the press, and the release lands at the last point reached. hold?: { durationMs: number moves?: Array<{ afterMs: number; x: number; y: number }> x: number y: number } | null rootFrame?: { height: number; width: number; x: number; y: number } | null settleMs?: number trigger?: () => unknown }, ): Promise<{ frames: Array<{ label: string offsetMs: number actualMs: number path: string frameIndex: number virtualTimestampMs: number }> triggerCompletedMs: number triggerStartMs: number visibleChangeDetection: { actualMs: number changedPixelPercent: number changedPixels: number channelThreshold: number frame: { height: number; width: number; x: number; y: number } | null minimumChangePercent: number rootFrame: { height: number; width: number; x: number; y: number } | null totalPixels: number frameIndex: number virtualTimestampMs: number } }> { const page = getPage() const token = `motion-${process.pid}-${Date.now()}-${Math.random()}` const settleMs = options.settleMs ?? 0 if (!Number.isFinite(settleMs) || settleMs < 0) { throw new Error('rnx motion capture needs a non-negative settleMs') } await page.evaluate(measureMotionChange, { definition: MOTION_CHANGE_DEFINITION, installGlobal: true, }) const capturePromise: Promise<{ frames: Array< FastScreenshotData & { frameIndex: number virtualTimestampMs: number } > visibleChangeDetection: { actualMs: number changedPixelPercent: number changedPixels: number channelThreshold: number frameIndex: number minimumChangePercent: number totalPixels: number virtualTimestampMs: number } }> = page.evaluate( async (input) => { type FrameReceipt = { frameIndex: number; virtualTimestampMs: number } type CaptureState = { cancelled: boolean ready: boolean triggerCompleted: boolean } type CaptureWindow = Window & { __sootsimMeasureMotionChange?: (input: { baseline: Uint8ClampedArray candidate: Uint8ClampedArray definition: MotionChangeDefinition height: number label: string width: number }) => MotionChangeMeasurement __sootsimVisibleChangeCaptures?: Record SootSim?: { bridges?: { interact?: { touchDown: (x: number, y: number) => Promise touchMove: (x: number, y: number) => Promise touchUp: (x: number, y: number) => Promise } liveComposite?: { captureFresh: () => Promise<{ canvas: HTMLCanvasElement | null frameToken: number }> } lockstepCapture?: { begin: (fps: number) => Promise<{ stepFrame: () => Promise stepToOffset: ( anchor: FrameReceipt, offsetMs: number, ) => Promise end: () => void }> } } } } type Snapshot = { actualMs: number bitmap: ImageBitmap label: string offsetMs: number frameIndex: number virtualTimestampMs: number } const captureWindow = window as unknown as CaptureWindow const live = captureWindow.SootSim?.bridges?.liveComposite const lockstep = captureWindow.SootSim?.bridges?.lockstepCapture if (!live || typeof live.captureFresh !== 'function') { throw new Error('rnx live composite bridge is not installed') } if (!lockstep || typeof lockstep.begin !== 'function') { throw new Error('SootSim lockstep capture bridge is not installed') } const registry = captureWindow.__sootsimVisibleChangeCaptures ?? (captureWindow.__sootsimVisibleChangeCaptures = {}) const state: CaptureState = { cancelled: false, ready: false, triggerCompleted: false, } registry[input.token] = state const measureChange = captureWindow.__sootsimMeasureMotionChange if (!measureChange) { throw new Error('rnx motion change definition is not installed') } const captureBitmap = async () => { const { canvas } = await live.captureFresh() if (!canvas) throw new Error('rnx live composite produced no canvas') return createImageBitmap(canvas) } const boundsFor = (bitmap: ImageBitmap) => { const scale = input.rootFrame && input.rootFrame.width > 0 ? bitmap.width / input.rootFrame.width : 1 return { height: input.frame ? Math.max( 1, Math.min( bitmap.height, Math.ceil((input.frame.y + input.frame.height) * scale), ) - Math.max(0, Math.floor(input.frame.y * scale)), ) : bitmap.height, left: input.frame ? Math.max(0, Math.floor(input.frame.x * scale)) : 0, top: input.frame ? Math.max(0, Math.floor(input.frame.y * scale)) : 0, width: input.frame ? Math.max( 1, Math.min( bitmap.width, Math.ceil((input.frame.x + input.frame.width) * scale), ) - Math.max(0, Math.floor(input.frame.x * scale)), ) : bitmap.width, } } const regionPixels = (bitmap: ImageBitmap) => { const bounds = boundsFor(bitmap) const canvas = document.createElement('canvas') canvas.width = bounds.width canvas.height = bounds.height const context = canvas.getContext('2d', { willReadFrequently: true }) if (!context) throw new Error('rnx motion detector needs a 2d canvas') context.drawImage( bitmap, bounds.left, bounds.top, bounds.width, bounds.height, 0, 0, bounds.width, bounds.height, ) return context.getImageData(0, 0, bounds.width, bounds.height).data } const encode = (snapshot: Snapshot) => { const canvas = document.createElement('canvas') canvas.width = snapshot.bitmap.width canvas.height = snapshot.bitmap.height const context = canvas.getContext('2d') if (!context) throw new Error('rnx motion capture needs a 2d canvas') context.drawImage(snapshot.bitmap, 0, 0) snapshot.bitmap.close() return { actualMs: snapshot.actualMs, dataUrl: canvas.toDataURL('image/png'), label: snapshot.label, offsetMs: snapshot.offsetMs, frameIndex: snapshot.frameIndex, virtualTimestampMs: snapshot.virtualTimestampMs, } } const interact = captureWindow.SootSim?.bridges?.interact if (input.hold && (!interact || typeof interact.touchDown !== 'function')) { throw new Error('rnx interact bridge is not installed') } const frameMs = 1000 / 60 const controller = await lockstep.begin(60) // the hold's pointer goes down before the first stepped frame, so its // start is one frame before the first receipt; each move and the release // land on the first stepped frame at or past their time from there. the // deadlines are frame indexes: comparing summed virtual timestamps // against a duration lets rounding push an event past its frame. const moves = input.hold?.moves ?? [] const holdState = { nextMove: 0, pressFrameIndex: null as number | null, released: false, x: input.hold?.x ?? 0, y: input.hold?.y ?? 0, } const holdEventFrameIndex = (afterMs: number) => (holdState.pressFrameIndex ?? 0) + Math.ceil(afterMs / frameMs) const nextHoldEventFrameIndex = () => { if (!input.hold || holdState.released || holdState.pressFrameIndex === null) { return null } const move = moves[holdState.nextMove] return holdEventFrameIndex(move ? move.afterMs : input.hold.durationMs) } const advanceHold = async (receipt: FrameReceipt) => { if (!input.hold || !interact || holdState.released) return if (holdState.pressFrameIndex === null) { holdState.pressFrameIndex = receipt.frameIndex - 1 } for ( let move = moves[holdState.nextMove]; move && receipt.frameIndex >= holdEventFrameIndex(move.afterMs); move = moves[holdState.nextMove] ) { holdState.nextMove++ holdState.x = move.x holdState.y = move.y await interact.touchMove(move.x, move.y) } if (receipt.frameIndex < holdEventFrameIndex(input.hold.durationMs)) return holdState.released = true await interact.touchUp(holdState.x, holdState.y) } try { const baselineBitmap = await captureBitmap() const baseline = regionPixels(baselineBitmap) baselineBitmap.close() state.ready = true if (input.hold && interact) { const hit = await interact.touchDown(input.hold.x, input.hold.y) if (!hit) { throw new Error(`rnx motion hold missed at ${input.hold.x},${input.hold.y}`) } } else { while (!state.triggerCompleted) { if (state.cancelled) throw new Error('rnx motion capture was cancelled') await new Promise((resolve) => setTimeout(resolve, 0)) } } let visibleStart: FrameReceipt | null = null let detectedMeasurement: MotionChangeMeasurement | null = null const detectionFrameLimit = Math.ceil((input.detectionTimeoutMs * 60) / 1000) for (let frame = 0; frame < detectionFrameLimit; frame++) { if (state.cancelled) throw new Error('rnx motion capture was cancelled') const receipt = await controller.stepFrame() await advanceHold(receipt) const candidateBitmap = await captureBitmap() const bounds = boundsFor(candidateBitmap) const measurement = measureChange({ baseline, candidate: regionPixels(candidateBitmap), definition: input.motionChangeDefinition, height: bounds.height, label: 'SootSim visible-change detector', width: bounds.width, }) candidateBitmap.close() if (measurement.passedMinimum) { visibleStart = receipt detectedMeasurement = measurement break } } if (!visibleStart || !detectedMeasurement) { throw new Error( `rnx motion had no visible change before ${input.detectionTimeoutMs}ms`, ) } const snapshots: Snapshot[] = [] for (const stage of input.stages) { if (state.cancelled) throw new Error('rnx motion capture was cancelled') // step through every hold move and the release that fall before // this stage one frame at a time, so the stage paints the pointer // where it belongs rather than a hold the jump skipped past. const stageFrameIndex = visibleStart.frameIndex + Math.ceil(stage.offsetMs / frameMs) for ( let eventFrameIndex = nextHoldEventFrameIndex(); eventFrameIndex !== null && stageFrameIndex > eventFrameIndex; eventFrameIndex = nextHoldEventFrameIndex() ) { await advanceHold(await controller.stepFrame()) } const receipt = await controller.stepToOffset(visibleStart, stage.offsetMs) await advanceHold(receipt) snapshots.push({ actualMs: receipt.virtualTimestampMs - visibleStart.virtualTimestampMs, bitmap: await captureBitmap(), frameIndex: receipt.frameIndex, label: stage.label, offsetMs: stage.offsetMs, virtualTimestampMs: receipt.virtualTimestampMs, }) } if (input.settleMs > 0) { const maximumOffsetMs = input.stages.reduce( (maximum, stage) => Math.max(maximum, stage.offsetMs), 0, ) await controller.stepToOffset(visibleStart, maximumOffsetMs + input.settleMs) } return { frames: snapshots.map(encode), visibleChangeDetection: { actualMs: (visibleStart.frameIndex * 1000) / 60, changedPixelPercent: detectedMeasurement.changePercent, changedPixels: detectedMeasurement.changePixels, channelThreshold: input.motionChangeDefinition.channelThreshold, minimumChangePercent: input.motionChangeDefinition.minimumChangePercent, totalPixels: detectedMeasurement.totalPixels, frameIndex: visibleStart.frameIndex, virtualTimestampMs: visibleStart.virtualTimestampMs, }, } } finally { controller.end() delete registry[input.token] } }, { detectionTimeoutMs: options.detectionTimeoutMs ?? 3000, frame: options.frame ?? null, hold: options.hold ?? null, motionChangeDefinition: MOTION_CHANGE_DEFINITION, rootFrame: options.rootFrame ?? null, settleMs, stages, token, }, ) await page.waitForFunction( (captureToken) => { const registry = ( window as Window & { __sootsimVisibleChangeCaptures?: Record< string, { cancelled: boolean; ready: boolean; triggerCompleted: boolean } > } ).__sootsimVisibleChangeCaptures return registry?.[captureToken]?.ready === true }, token, { timeout: 5000 }, ) const triggerStartMs = Date.now() let triggerCompletedMs = 0 try { if (!options.hold) { if (typeof options.trigger !== 'function') { throw new Error('rnx motion capture needs a trigger or a hold') } await Promise.resolve(options.trigger()) } triggerCompletedMs = Date.now() await page.evaluate((captureToken) => { const registry = ( window as Window & { __sootsimVisibleChangeCaptures?: Record< string, { cancelled: boolean; ready: boolean; triggerCompleted: boolean } > } ).__sootsimVisibleChangeCaptures const capture = registry?.[captureToken] if (!capture) throw new Error('rnx motion capture disarmed before trigger') capture.triggerCompleted = true }, token) const capture = await capturePromise return { frames: writeFastScreenshotFrames(namePrefix, capture.frames), triggerCompletedMs, triggerStartMs, visibleChangeDetection: { ...capture.visibleChangeDetection, frame: options.frame ?? null, rootFrame: options.rootFrame ?? null, }, } } catch (error) { await page .evaluate((captureToken) => { const registry = ( window as Window & { __sootsimVisibleChangeCaptures?: Record< string, { cancelled: boolean; ready: boolean; triggerCompleted: boolean } > } ).__sootsimVisibleChangeCaptures const capture = registry?.[captureToken] if (capture) capture.cancelled = true }, token) .catch(() => {}) await capturePromise.catch(() => {}) throw error } }, // FAST multi-stage capture for sub-second animation diffs. each stage // schedules a setTimeout at its requested offsetMs and at that moment // SYNCHRONOUSLY snapshots the engine's `liveComposite` host-side canvas // (drawImage from the worker-transferred home + overlay canvases). PNG // encoding is queued after all snapshots land — encoding stalls don't // contaminate the animation window. // // unlike `takeScreenshotStages`, this does NOT go through screenshot() // (which calls forceRenderAll + composite + toDataURL serially at // 300-1300ms per call and blows the spring-open timing). the worker's // vsync-pumped rAF keeps the home canvas fresh; we just sample it. // // returns paths in stage order; actualMs reports when the snapshot // actually fired (typically within a few ms of offsetMs). async takeScreenshotStagesFast( namePrefix: string, stages: Array<{ label: string; offsetMs: number }>, options: { anchorAfterTrigger?: boolean skipWarmup?: boolean trigger: () => unknown }, ): Promise<{ captureAnchorMs: number frames: Array<{ label: string offsetMs: number actualMs: number captureRequestedAtMs: number path: string }> triggerCompletedMs: number triggerStartMs: number }> { const page = getPage() const token = `timed-${process.pid}-${Date.now()}-${Math.random()}` const capturePromise: Promise<{ captureAnchorMs: number frames: Array<{ label: string offsetMs: number actualMs: number captureRequestedAtMs: number dataUrl: string }> }> = page.evaluate( async (input) => { type CaptureState = { anchorPerformanceMs: number | null anchorWallMs: number | null cancelled: boolean ready: boolean } type CaptureWindow = Window & { __sootsimTimedCaptures?: Record SootSim?: { bridges?: { screenshot?: (opts?: unknown) => Promise liveComposite?: { captureFresh: () => Promise<{ canvas: HTMLCanvasElement | null frameToken: number }> } } } } const captureWindow = window as unknown as CaptureWindow const screenshot = captureWindow.SootSim?.bridges?.screenshot const live = captureWindow.SootSim?.bridges?.liveComposite if (typeof screenshot !== 'function') { throw new Error('rnx screenshot bridge is not installed') } const registry = captureWindow.__sootsimTimedCaptures ?? (captureWindow.__sootsimTimedCaptures = {}) const state: CaptureState = { anchorPerformanceMs: null, anchorWallMs: null, cancelled: false, ready: false, } registry[input.token] = state // initial warm-up: ensure the worker has produced at least one // frame after the menu's portal mount before we start sampling. // (a pump at higher cadence was tried and starved tap propagation — // the tenant React commit for setOpen never landed between // forceRender calls.) if (!input.skipWarmup) { await screenshot({ format: 'png' }) } state.ready = true while (state.anchorPerformanceMs === null || state.anchorWallMs === null) { if (state.cancelled) throw new Error('rnx timed capture was cancelled') await new Promise((resolve) => setTimeout(resolve, 0)) } const captureAnchorMs = state.anchorWallMs const captureAnchorPerformanceMs = state.anchorPerformanceMs type Snapshot = { label: string offsetMs: number actualMs: number captureRequestedAtMs: number dataUrl: string | null } // each stage fires at its requested offset and SYNCHRONOUSLY // snapshots the engine's host-side composite canvas (drawImage // from the worker-transferred home canvas + overlay). encoding // happens after — encoding stalls don't disturb the next stage's // capture moment. const snaps: Snapshot[] = await Promise.all( input.stages.map( (stage) => new Promise((resolve) => { const fire = async () => { const actualMs = performance.now() - captureAnchorPerformanceMs const captureRequestedAtMs = Date.now() try { let dataUrl: string | null = null if (live && typeof live.captureFresh === 'function') { // synchronous paint of current canvas state — no // worker round-trip. the background pump keeps the // canvas fresh. const { canvas } = await live.captureFresh() if (canvas) { const bmp = await createImageBitmap(canvas) const enc = document.createElement('canvas') enc.width = bmp.width enc.height = bmp.height const ctx = enc.getContext('2d') if (ctx) { ctx.drawImage(bmp, 0, 0) dataUrl = enc.toDataURL('image/png') } bmp.close() } } if (!dataUrl) { dataUrl = await screenshot({ format: 'png' }) } resolve({ label: stage.label, offsetMs: stage.offsetMs, actualMs, captureRequestedAtMs, dataUrl, }) } catch { resolve({ label: stage.label, offsetMs: stage.offsetMs, actualMs, captureRequestedAtMs, dataUrl: null, }) } } setTimeout( () => { void fire() }, Math.max( 0, captureAnchorPerformanceMs + stage.offsetMs - performance.now(), ), ) }), ), ) const out: Array<{ label: string offsetMs: number actualMs: number captureRequestedAtMs: number dataUrl: string }> = [] for (const snap of snaps) { if (!snap.dataUrl) continue out.push({ label: snap.label, offsetMs: snap.offsetMs, actualMs: snap.actualMs, captureRequestedAtMs: snap.captureRequestedAtMs, dataUrl: snap.dataUrl, }) } delete registry[input.token] return { captureAnchorMs, frames: out } }, { stages, skipWarmup: options.skipWarmup === true, token }, ) await page.waitForFunction( (captureToken) => { const registry = ( window as Window & { __sootsimTimedCaptures?: Record } ).__sootsimTimedCaptures return registry?.[captureToken]?.ready === true }, token, { timeout: 5000 }, ) const armCapture = (anchorMs?: number) => page.evaluate( ({ anchorMs, captureToken }) => { const registry = ( window as Window & { __sootsimTimedCaptures?: Record< string, { anchorPerformanceMs: number | null anchorWallMs: number | null ready: boolean } > } ).__sootsimTimedCaptures const capture = registry?.[captureToken] if (!capture?.ready) throw new Error('rnx timed capture was not armed') const nowMs = Date.now() capture.anchorWallMs = anchorMs ?? nowMs capture.anchorPerformanceMs = performance.now() - (nowMs - capture.anchorWallMs) return capture.anchorWallMs }, { anchorMs, captureToken: token }, ) let triggerStartMs = 0 let triggerCompletedMs = 0 try { let captureAnchorMs: number if (options.anchorAfterTrigger === false) { captureAnchorMs = await armCapture() triggerStartMs = captureAnchorMs const triggerPromise = Promise.resolve() .then(options.trigger) .then(() => { triggerCompletedMs = Date.now() }) const [capture] = await Promise.all([capturePromise, triggerPromise]) return { captureAnchorMs, frames: writeFastScreenshotFrames(namePrefix, capture.frames), triggerCompletedMs, triggerStartMs, } } triggerStartMs = Date.now() await Promise.resolve(options.trigger()) triggerCompletedMs = Date.now() captureAnchorMs = await armCapture(triggerCompletedMs) const capture = await capturePromise return { captureAnchorMs, frames: writeFastScreenshotFrames(namePrefix, capture.frames), triggerCompletedMs, triggerStartMs, } } catch (error) { await page .evaluate((captureToken) => { const registry = ( window as Window & { __sootsimTimedCaptures?: Record } ).__sootsimTimedCaptures const capture = registry?.[captureToken] if (capture) capture.cancelled = true }, token) .catch(() => {}) await capturePromise.catch(() => {}) throw error } }, // batched multi-stage capture for animation diffs. all N captures happen // inside one page.evaluate round trip so browser-side timing controls the // offsets — sidesteps the ~300ms per-call bridge overhead that blows // sub-second animation windows. each stage gets a screenshot at the // requested offsetMs (from when the evaluate starts, i.e. right after // trigger() returns). returns absolute paths in stage order. async takeScreenshotStages( namePrefix: string, stages: Array<{ label: string; offsetMs: number }>, ): Promise> { const page = getPage() const frames: Array<{ label: string offsetMs: number actualMs: number dataUrl: string }> = await page.evaluate( async (input) => { const screenshot = ( window as { SootSim?: { bridges?: { screenshot?: unknown } } } ).SootSim?.bridges?.screenshot as | ((opts?: unknown) => Promise) | undefined if (typeof screenshot !== 'function') { throw new Error('rnx screenshot bridge is not installed') } const start = Date.now() const out: Array<{ label: string offsetMs: number actualMs: number dataUrl: string }> = [] for (const stage of input.stages) { const elapsed = Date.now() - start const wait = Math.max(0, stage.offsetMs - elapsed) if (wait > 0) await new Promise((r) => setTimeout(r, wait)) const actualMs = Date.now() - start const dataUrl = await screenshot({ format: 'png' }) out.push({ label: stage.label, offsetMs: stage.offsetMs, actualMs, dataUrl }) } return out }, { stages }, ) return writeFastScreenshotFrames(namePrefix, frames) }, async shake() { // no-op for sootsim }, async setLocation(lat: number, lon: number) { // no-op }, async setStatusBar(config: StatusBarConfig) { await dispatchStatusBarOverride(config) }, async setURLBlacklist(urls: string[]) { // no-op }, async enableSynchronization() { _synchronizationEnabled = true }, async disableSynchronization() { _synchronizationEnabled = false }, getPlatform(): 'ios' | 'android' { return device._platform }, async pressBack() { if (device._platform !== 'android') return const page = getPage() const dispatched = await page.evaluate(async () => { const goBack = window.SootSim?.bridges?.mainShell?.goBack if (typeof goBack !== 'function') return false return (await goBack()) !== false }) if (!dispatched) throw new Error('sootsim Android back bridge is not installed') if (_synchronizationEnabled) { await waitForSootsimIdle(page) } }, // a real browser key press. host.ts turns window keydown into the // `sootsim:hostKey` shell event that overlays listen to, so this drives the // same path a desktop user does for keys no device button covers (Escape). async pressKey(key: string) { const page = getPage() await page.keyboard.press(key) if (_synchronizationEnabled) { await waitForSootsimIdle(page) } }, // raw coordinate tap, bypassing the element-based visibility/hit-test path. // mirrors detox 20.x device.tap({ x, y }) on real iOS — used when the // target is occluded by a native UIWindow (e.g. UIMenu's dim layer) so // `element(...).tap({x,y})` would fail Detox's visibility threshold. async tap(point?: { x: number; y: number } | boolean) { if (point == null || typeof point === 'boolean') return const page = getPage() const { x, y } = point await page.evaluate( async ({ x: tx, y: ty }) => { const interactTap = window.SootSim?.bridges?.interact?.tap if (typeof interactTap === 'function') { const result = await interactTap(tx, ty) if (result && (typeof result !== 'object' || result.hit !== false)) return } const tap = window.__sootsimTest?.tap if (typeof tap !== 'function') { throw new Error('sootsim tap bridge is not installed') } await tap(tx, ty) }, { x, y }, ) await page.waitForTimeout(50) if (_synchronizationEnabled) { await waitForSootsimIdle(page) } }, // window-point counterpart to element(...).longPress, for a target detox can // only address by coordinate (a native tab cell, a shell chrome control). async longPress(point: { x: number; y: number }, durationMs = 1000) { const page = getPage() const { x, y } = point await page.evaluate( async ({ x: px, y: py, ms }) => { const longPress = window.SootSim?.bridges?.interact?.longPress if (typeof longPress !== 'function') { throw new Error('sootsim longPress bridge is not installed') } const hit = await longPress(px, py, ms) if (!hit) { throw new Error(`sootsim longPress missed at ${px},${py}`) } }, { x, y, ms: durationMs }, ) await page.waitForTimeout(50) if (_synchronizationEnabled) { await waitForSootsimIdle(page) } }, async sendToHome() { const page = getPage() _suspendedAppId = await page.evaluate(async () => { const state = await window.SootSim?.bridges?.mainShell?.getState?.() const appId = state?.activeApp if (typeof appId !== 'string') { throw new Error(`cannot send an inactive SootSim app home`) } window.SootSim?.bridges?.mainShell?.goHome?.() return appId }) await pollUntil( () => page.evaluate(async () => { const state = await window.SootSim?.bridges?.mainShell?.getState?.() return state?.state === 'home' && state.switcherPhase === 'idle' }), Boolean, { timeoutMs: 10000, label: 'SootSim home transition', }, ) }, // get direct access to the playwright page for advanced use getPage(): Page { return getPage() }, } // exports matching detox API const sootsimExpect = createExpect(findNodeByMatcher, getPage) const sootsimWaitFor = createWaitFor(findNodeByMatcher, getPage) export { sootsimExpect as expect, sootsimWaitFor as waitFor } // cleanup -- call this in afterAll export async function cleanup() { await closeBrowser() }