import type { InspectBridge } from './core' export type WaitForSootsimIdleOptions = { bridge: InspectBridge simId?: string maxMs: number pollMs?: number stablePolls?: number strict?: boolean } export type WaitForSootsimIdleResult = { elapsed: number settled: boolean // what the last poll still saw moving. a settle that only reports "did not // settle" cannot tell a slow screen from one that animates forever, and the // two need opposite fixes. blockedBy: string } export async function waitForSootsimIdle({ bridge, simId, maxMs, pollMs = 50, stablePolls = 3, strict = false, }: WaitForSootsimIdleOptions): Promise { const result = await bridge.send( { type: 'evaluate', simId, code: `(async () => { const start = Date.now() const deadline = start + ${Math.max(0, Math.round(maxMs))} const pollMs = ${Math.max(1, Math.round(pollMs))} const requiredStablePolls = ${Math.max(1, Math.round(stablePolls))} const strict = ${strict ? 'true' : 'false'} const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) // route-aware settle: a tap that pushes/pops a screen is async — the // old screen still renders for a moment, then the new one mounts and // its content loads. a pure layout-hash check reports "idle" on the // outgoing screen or on the incoming skeleton, so a driver re-taps // and stacks duplicate navigations. drain any in-flight screen // transition FIRST, bounded by the overall budget. when no // transition is happening this returns in ~80ms (startWindowMs), so // a plain button tap barely pays for it. try { const wfst = window.__sootsimTest?.waitForScreenTransitions if (typeof wfst === 'function') { const remaining = deadline - Date.now() if (remaining > 120) { await wfst({ timeoutMs: Math.min(remaining - 80, 4000), settleMs: 64, startWindowMs: 80, }) } } } catch {} const readSnapshot = async () => { let animating = false let layoutDirty = false let pendingFetches = 0 let requestTotal = 0 let requestInFlight = 0 let renderStatsAvailable = false try { const stats = await window.__sootsimRenderHost?.queryStats?.() if (stats) { renderStatsAvailable = true animating = stats.hasActiveAnims === true || stats.hasActiveNativeAnimations === true || stats.hasPendingAnimationFrames === true layoutDirty = stats.layoutDirty === true && stats.renderRequested === true // a freshly-pushed screen showing a skeleton is layout-stable // but not actually settled — its data/images are still in // flight. treat bounded image-loader fetches as not-idle so // settle waits for real content, capped by the budget. const pf = stats.memory && stats.memory.imageLoader ? stats.memory.imageLoader.pendingFetches : 0 pendingFetches = typeof pf === 'number' ? pf : 0 } } catch {} try { const counts = await window.__sootsimTest?.getRequestCounts?.() requestTotal = typeof counts?.total === 'number' ? counts.total : 0 requestInFlight = typeof counts?.inFlight === 'number' ? counts.inFlight : 0 } catch {} const root = window.__sootsimRoot const nodes = [] if (root) { const walk = (n) => { if (n.layout && n.layout.width > 0) { nodes.push(Math.round(n.layout.x) + ',' + Math.round(n.layout.y) + ',' + Math.round(n.layout.width)) } for (const c of n.children || []) walk(c) } walk(root) } return { layout: nodes.join(';'), animating, layoutDirty, pendingFetches, renderStatsAvailable, requestTotal, requestInFlight } } // how long generic background network is allowed to keep us waiting AFTER // the screen is otherwise visually settled. apps with continuous traffic // (polling, realtime sockets, a tap that fires 20-50 fetches) never reach // requestInFlight===0 / a stable requestTotal, so requiring full network // quiet burns the ENTIRE budget on every command — the leading idle that // failed PR-preview recordings for network-heavy apps (3pc fight-pulse). // layout-stability + the image-loader already prove visible content // loaded (a data load changes layout; an image load shows in the loader), // so the generic request counters are a courtesy, not a gate: honor them // only within this grace once the screen is visually still. const networkQuietGraceMs = 1200 let lastLayout = '' let lastRequestTotal = -1 let stable = 0 let visuallyStillSince = 0 // counts across the whole budget: a flag that is true on every poll is a // different bug from one that is true intermittently, and the two need // opposite fixes (engine vs budget). let polls = 0 let dirtyPolls = 0 let animatingPolls = 0 let layoutChangedPolls = 0 while (Date.now() < deadline) { const snapshot = await readSnapshot() polls++ if (snapshot.layoutDirty) dirtyPolls++ if (snapshot.animating) animatingPolls++ if (polls > 1 && snapshot.layout !== lastLayout) layoutChangedPolls++ const strictOk = !strict || (snapshot.renderStatsAvailable && !snapshot.animating && !snapshot.layoutDirty) // visually settled: no animations, no image content loading, layout // unchanged since the last poll. const visuallyStill = strictOk && snapshot.pendingFetches === 0 && snapshot.layout === lastLayout // generic network quiet: nothing in flight and no new requests issued. const networkQuiet = snapshot.requestInFlight === 0 && snapshot.requestTotal === lastRequestTotal if (visuallyStill) { stable++ if (visuallyStillSince === 0) visuallyStillSince = Date.now() // settle once the screen has held still long enough AND either the // network is genuinely quiet OR it has refused to quiet within the // grace (continuous background traffic must not block forever). if ( stable >= requiredStablePolls && (networkQuiet || Date.now() - visuallyStillSince >= networkQuietGraceMs) ) { return { settled: true, elapsed: Date.now() - start, blockedBy: '' } } } else { stable = 0 visuallyStillSince = 0 } lastLayout = snapshot.layout lastRequestTotal = snapshot.requestTotal await sleep(pollMs) } const last = await readSnapshot() const blockers = [] if (strict && !last.renderStatsAvailable) blockers.push('render stats unavailable') if (last.animating) blockers.push('animations still running') if (last.layoutDirty) blockers.push('layout still dirty') if (last.pendingFetches > 0) blockers.push(last.pendingFetches + ' image fetches pending') if (last.layout !== lastLayout) blockers.push('layout still changing') if (last.requestInFlight > 0) blockers.push(last.requestInFlight + ' requests in flight') return { settled: false, elapsed: Date.now() - start, blockedBy: (blockers.length ? blockers.join(', ') : 'unknown') + ' over ' + polls + ' polls (layout changed ' + layoutChangedPolls + 'x, layoutDirty ' + dirtyPolls + 'x, animating ' + animatingPolls + 'x)', } })()`, }, { timeoutMs: maxMs + 1_000, }, ) const { elapsed, settled, blockedBy } = (result ?? {}) as Partial return { elapsed: typeof elapsed === 'number' ? elapsed : maxMs, settled: settled === true, blockedBy: typeof blockedBy === 'string' ? blockedBy : 'unknown', } }