// rnx debug — drive __sootsimDebug.* from the terminal // // connects to a running rnx instance via the WebSocket bridge and forwards // debug channel management, tree snapshots, and on-demand inspectors without // requiring a browser console. // // usage examples: // rnx debug enable sheets,portals,onlayout // rnx debug status // rnx debug snapshot before // # ... tap something in the UI ... // rnx debug snapshot after // rnx debug diff before after // rnx debug find sheets // rnx debug find portals // rnx debug recent portals // rnx debug flags // // all commands print JSON by default so they pipe cleanly into jq. import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants' import { printBridgeFailureDiagnostics } from '../bridge-diagnostics' import { rethrowIfExit, rnxExit } from '../run-rnx' import { createBridgeFromParsed, evalInBridge, parseBridgeCliArgs, type WsBridge, } from '../ws-bridge' import { callDebugBridge, inspectDebugFind, inspectDebugFlags, inspectDebugRecent, inspectDebugStatus, setDebugChannels, } from './inspect/core' import { callTestBridge } from './inspect/shared' interface DebugOptions { port?: number verbose?: boolean } const KNOWN_CHANNELS = [ 'portals', 'sheets', 'layout', 'onlayout', 'animated', 'render', 'touch', 'yoga', 'all', ] as const const SHELL_TRACE_STORE_GLOBAL = '__sootsimShellAnimationTrace' const SHELL_TRACE_HOOK_GLOBAL = '__sootsimDebugAnimation' function printHelp() { console.log(` rnx debug — drive __sootsimDebug from the terminal usage: rnx debug [args] record, recent, and clear-events target the tenant; --host selects the page buffer. enable/disable/toggle also update the tenant unless --host is passed. shell event buffers are separate; shell console logs are forwarded to the host. subcommands: enable turn on host, shell/compositor, and tenant channels channels: ${KNOWN_CHANNELS.slice(0, -1).join(', ')} or 'all' for every channel disable turn off one or more debug channels (or 'all') toggle flip a single channel status list host channels forwarded to shell/compositor channels list every known channel name flags print the host DEBUG flag object state ... dump raw runtime state (diagnostic — not a getter) kinds: shell, worker, keyboard, node , scroll , scroll-hit , hit , gesture js execute javascript in the running app. rnx is the canonical state object — reach into its bridges / state / chrome / debug slots directly. e.g. SootSim.bridges.test.findByText("Sign in") eval alias for js perf shell frame profiling: start, stop, transition memory [--watch [s]] per-worker object counts + heaps; --watch samples every --interval seconds and reports slopes sample-color <...> sample averaged color from canvas (same flags as screenshot) snapshot [label] capture a tree snapshot (layout + transform + opacity) snapshots list taken snapshot labels diff compare two snapshots by label clear-snapshots [label] drop a snapshot (or all if no label given) image-audit for every image the compositor actually drew, the source pixels it landed on each device pixel. a row whose shortfall is well under 1.0 is an image the pipeline under-resolved; 0.5 is half resolution. flags: --min (default 0.98), --all, --json exits 1 when any image is under the ratio, and 2 when the audit observed no image draws at all find sheets dump Sheet.Frame-shaped nodes currently in the tree find portals dump portal-host / portal-view nodes find boundaries dump live paint boundaries (+ why scroll rows were rejected from the auto-retained raster tier) trace shell [cmd] opt-in shell animation trace cmds: on [limit], off, status, clear, [recentLimit] trace anim opt-in per-tick animation value trace subs: on [limit], off [id|all], status, clear, [limit] record [on|off] toggle event ring buffer recording recent [channel] [n] last N events (default 50), optionally filtered clear-events clear the event ring buffer options: --port WS bridge port (default: ${DEFAULT_SOOTSIM_BRIDGE_PORT}) --sim target a specific connected sim --pretty format JSON output for humans (default) --json compact JSON output for pipelines examples: rnx debug enable sheets,portals rnx debug snapshot before rnx debug snapshot after rnx debug diff before after rnx debug image-audit per-image source pixels per device pixel rnx debug find sheets | jq '.[] | select(.translateY != null)' rnx debug trace shell on 240 rnx debug trace shell 120 rnx debug trace anim on 3 240 rnx debug trace anim 3 60 `) } function parseChannelList(raw: string | undefined): string[] { if (!raw) return [] return raw .split(',') .map((s) => s.trim()) .filter(Boolean) } function fmt(value: unknown, pretty: boolean): string { if (value === undefined) return '' return JSON.stringify(value, null, pretty ? 2 : 0) } function buildShellTraceControlCode( action: 'on' | 'off' | 'clear' | 'status', limit = 240, ): string { const normalizedLimit = Number.isFinite(limit) && limit > 0 ? Math.max(1, Math.round(limit)) : 240 const storeKey = JSON.stringify(SHELL_TRACE_STORE_GLOBAL) const hookKey = JSON.stringify(SHELL_TRACE_HOOK_GLOBAL) if (action === 'on') { return `(() => { const root = window const store = root[${storeKey}] || (root[${storeKey}] = { enabled: false, limit: ${normalizedLimit}, seq: 0, samples: [], prevHook: undefined, hook: undefined, }) store.limit = ${normalizedLimit} store.enabled = true const currentHook = root[${hookKey}] if (store.hook !== currentHook) { store.prevHook = typeof currentHook === 'function' ? currentHook : undefined } if (typeof store.hook !== 'function') { store.hook = (sample) => { const target = root[${storeKey}] if (!target || target.enabled !== true) return const samples = Array.isArray(target.samples) ? target.samples : (target.samples = []) target.seq = typeof target.seq === 'number' ? target.seq + 1 : 1 samples.push({ seq: target.seq, ...sample }) if (samples.length > target.limit) { samples.splice(0, samples.length - target.limit) } if (typeof target.prevHook === 'function') { try { target.prevHook(sample) } catch {} } } } root[${hookKey}] = store.hook return { enabled: true, limit: store.limit, count: Array.isArray(store.samples) ? store.samples.length : 0, } })()` } if (action === 'off') { return `(() => { const root = window const store = root[${storeKey}] if (!store) return { enabled: false, limit: 0, count: 0 } store.enabled = false if (root[${hookKey}] === store.hook) { root[${hookKey}] = typeof store.prevHook === 'function' ? store.prevHook : undefined } return { enabled: false, limit: typeof store.limit === 'number' ? store.limit : 0, count: Array.isArray(store.samples) ? store.samples.length : 0, } })()` } if (action === 'clear') { return `(() => { const store = window[${storeKey}] if (!store) return { cleared: true, count: 0 } store.samples = [] store.seq = 0 return { cleared: true, enabled: store.enabled === true, limit: typeof store.limit === 'number' ? store.limit : 0, count: 0, } })()` } return `(() => { const store = window[${storeKey}] return { enabled: !!store?.enabled, limit: typeof store?.limit === 'number' ? store.limit : 0, count: Array.isArray(store?.samples) ? store.samples.length : 0, } })()` } function buildShellTraceRecentCode(limit = 50): string { const normalizedLimit = Number.isFinite(limit) && limit > 0 ? Math.max(1, Math.round(limit)) : 50 const storeKey = JSON.stringify(SHELL_TRACE_STORE_GLOBAL) return `(() => { const store = window[${storeKey}] const all = Array.isArray(store?.samples) ? store.samples : [] const samples = all.slice(-${normalizedLimit}) const first = samples[0] ?? null const last = samples[samples.length - 1] ?? null const uniquePhases = [...new Set(samples.map((sample) => sample?.phase).filter(Boolean))] const metricRange = (key) => { const values = samples .map((sample) => (typeof sample?.[key] === 'number' ? sample[key] : null)) .filter((value) => value != null) if (values.length === 0) return null return { min: Math.min(...values), max: Math.max(...values), } } return { enabled: !!store?.enabled, limit: typeof store?.limit === 'number' ? store.limit : 0, count: samples.length, total: all.length, summary: first && last ? { durationMs: last.ts - first.ts, phases: uniquePhases, start: first, end: last, zoomLevel: metricRange('zoomLevel'), horizontalZoom: metricRange('horizontalZoom'), launchProgress: metricRange('launchProgress'), dismissOffsetY: metricRange('dismissOffsetY'), } : null, samples, } })()` } async function call(bridge: WsBridge, code: string): Promise { return evalInBridge(bridge, code) } interface ImageAuditRowView { nodeId: number type: string testID: string | null sourceUri: string | null resizeMode: string | null sampling: string | null vector: boolean intrinsic: { width: number; height: number } | null decoded: { width: number; height: number } sampled: { width: number; height: number } destination: { x: number; y: number; width: number; height: number } decision: { gpuBacked: boolean; hasMipmaps: boolean } deviceScale: number dpr: number needed: { width: number; height: number } effective: { width: number; height: number } ceiling: { width: number; height: number } | null shortfall: number | null } interface ImageAuditReportView { draws: number samplingCounts: { mipmapped: number; cubic: number; repeat: number; none: number } rows: ImageAuditRowView[] } function readNumberFlag(args: string[], flag: string): number | null { const index = args.indexOf(flag) if (index < 0) return null const parsed = Number(args[index + 1]) return Number.isFinite(parsed) ? parsed : null } // enable the audit, force one real frame, then read and stop. the screenshot // request is what forces the frame: it runs the same publish-and-paint pass // `rnx screenshot` does, so the rows describe pixels that were actually // produced rather than a tree walk's guess about them. async function runImageAudit(bridge: WsBridge): Promise { await call( bridge, "window.__sootsimCompositor.getStats(false, false, undefined, 'enable').then(() => 'enabled')", ) await bridge.send({ type: 'screenshot' }) const stats = await call<{ imageAudit?: ImageAuditReportView | null }>( bridge, "window.__sootsimCompositor.getStats(false, false, undefined, 'disable').then((s) => ({ imageAudit: s.imageAudit }))", ) const report = stats?.imageAudit if (!report) throw new Error('compositor returned no image audit') return report } function ratioText(value: number | null): string { return value === null ? ' - ' : value.toFixed(3).padStart(6) } function sourceText(row: ImageAuditRowView): string { const uri = row.sourceUri ?? row.testID ?? `node ${row.nodeId}` if (uri.length <= 58) return uri return `...${uri.slice(-55)}` } function printImageAudit( report: ImageAuditReportView, rows: ImageAuditRowView[], minimum: number, ): void { const withCeiling = report.rows.filter((row) => row.shortfall !== null) const under = withCeiling.filter((row) => row.shortfall! < minimum) console.log( ` ${report.rows.length} images drawn (${report.draws} draws), ` + `${under.length} below ${minimum} of what their source could deliver`, ) console.log( ` sampling: mipmapped ${report.samplingCounts.mipmapped} · cubic ${report.samplingCounts.cubic} · ` + `repeat ${report.samplingCounts.repeat} · none ${report.samplingCounts.none}`, ) if (rows.length === 0) { console.log(' every image delivered its source resolution') return } console.log('') console.log(' short eff decoded source needed(dev) dest(pt) dpr image') for (const row of rows) { const decoded = `${row.decoded.width}x${row.decoded.height}`.padEnd(11) const intrinsic = ( row.vector ? 'vector' : row.intrinsic ? `${row.intrinsic.width}x${row.intrinsic.height}` : '?' ).padEnd(11) const needed = `${Math.round(row.needed.width)}x${Math.round(row.needed.height)}`.padEnd(12) const dest = `${Math.round(row.destination.width)}x${Math.round(row.destination.height)}`.padEnd( 9, ) console.log( ` ${ratioText(row.shortfall)} ${ratioText(row.effective.width)} ` + `${decoded} ${intrinsic} ${needed} ${dest} ${row.dpr.toFixed(1)} ${sourceText(row)}`, ) } } export async function runDebug(args: string[], opts: DebugOptions) { const parsed = parseBridgeCliArgs(args, { port: opts.port, stripBooleanFlags: ['--pretty', '--json', '--help', '-h', '--host'], }) const positional = parsed.positional const subcommand = positional[0] if (!subcommand || args.includes('--help') || args.includes('-h')) { printHelp() rnxExit(0) } const wsPort = parsed.wsPort const simId = parsed.simId const pretty = !args.includes('--json') const simHint = simId ? ` --sim ${simId}` : '' const sub = positional[0] const rest = positional.slice(1) // these subcommands are organized under `debug` for the user but their // implementation lives in inspect.ts. forward to runInspect with the raw // `debug` prefix intact — runInspect strips it. done before we open a // debug-side bridge so we don't hold two bridges at once. const INSPECT_FORWARDED = new Set(['state', 'js', 'perf', 'memory', 'sample-color']) if (INSPECT_FORWARDED.has(sub)) { const { runInspect } = await import('./inspect') // pass the raw arg list through so every flag (--sim, --json, // --verbose, per-subcommand flags, etc.) reaches the inspect parser // untouched. the leading 'debug' is recognized as a verb prefix by // runInspect and stripped. await runInspect(['debug', ...args], { port: opts.port, verbose: opts.verbose, }) return } const bridge = createBridgeFromParsed(parsed) try { switch (sub) { case 'enable': { const channels = parseChannelList(rest[0]) if (channels.length === 0) { console.error( ` usage: rnx debug enable \n known: ${KNOWN_CHANNELS.join(', ')}`, ) rnxExit(1) } const active = await setDebugChannels( bridge, 'enable', channels, args.includes('--host'), ) console.log(fmt({ active }, pretty)) break } case 'disable': { const channels = parseChannelList(rest[0]) const active = await setDebugChannels( bridge, 'disable', channels, args.includes('--host'), ) console.log(fmt({ active }, pretty)) break } case 'toggle': { const channel = rest[0] if (!channel) { console.error(' usage: rnx debug toggle ') rnxExit(1) } const result = await callDebugBridge( bridge, `toggle(${JSON.stringify(channel)})`, true, ) if (!args.includes('--host')) { await callDebugBridge( bridge, `${result ? 'enable' : 'disable'}(${JSON.stringify(channel)})`, ) } console.log(fmt({ [channel]: result }, pretty)) break } case 'status': { const result = await inspectDebugStatus(bridge) console.log(fmt(result, pretty)) break } case 'channels': { const result = await call(bridge, 'window.__sootsimDebug.channels()') console.log(fmt(result, pretty)) break } case 'flags': { const result = await inspectDebugFlags(bridge) console.log(fmt(result, pretty)) break } case 'snapshot': { const label = rest[0] const code = label ? `window.__sootsimDebug.snapshot(${JSON.stringify(label)})` : 'window.__sootsimDebug.snapshot()' // the tree snapshot contains a Map, which doesn't serialize cleanly. // project to the same shape the diff command will accept: label + // size. to inspect contents, use `find sheets` / `find portals` or // take a diff. const result = await call<{ label: string at: number nodes: unknown }>( bridge, `(() => { const s = ${code}; if (!s) return null; return { label: s.label, at: s.at, size: s.nodes.size }; })()`, ) console.log(fmt(result, pretty)) break } case 'snapshots': { const result = await call(bridge, 'window.__sootsimDebug.snapshots()') console.log(fmt(result, pretty)) break } case 'diff': { const a = rest[0] const b = rest[1] if (!a || !b) { console.error(' usage: rnx debug diff ') rnxExit(1) } // Maps don't serialize — project added/removed/changed arrays only const code = `(() => { const d = window.__sootsimDebug.diff(${JSON.stringify(a)}, ${JSON.stringify(b)}); if (!d) return null; return { a: d.a, b: d.b, counts: { added: d.added.length, removed: d.removed.length, changed: d.changed.length }, added: d.added, removed: d.removed, changed: d.changed, }; })()` const result = await call(bridge, code) console.log(fmt(result, pretty)) break } case 'clear-snapshots': { const label = rest[0] const code = label ? `window.__sootsimDebug.clearSnapshots(${JSON.stringify(label)})` : 'window.__sootsimDebug.clearSnapshots()' await call(bridge, code) console.log(fmt({ cleared: label || 'all' }, pretty)) break } case 'image-audit': { const minimum = readNumberFlag(args, '--min') ?? 0.98 const report = await runImageAudit(bridge) if (report.draws === 0) { console.error( ' the audit recorded zero image draws, so an empty result proves nothing.\n' + ' while it records, paint boundaries rebuild and raster blits are\n' + ' bypassed, so every image on screen should reach the draw path.\n' + ' zero draws means either nothing on screen is an image, or the audit\n' + ' is not attached to the surface that paints.', ) rnxExit(2) } const under = report.rows.filter( (row) => row.shortfall !== null && row.shortfall < minimum, ) if (!pretty) { console.log(fmt({ ...report, minimum, under: under.length }, false)) } else { printImageAudit(report, args.includes('--all') ? report.rows : under, minimum) } if (under.length > 0) rnxExit(1) break } case 'find': { const target = rest[0] if (target === 'sheets' || target === 'portals' || target === 'boundaries') { const result = await inspectDebugFind(bridge, target) console.log(fmt(result, pretty)) } else { console.error(' usage: rnx debug find ') rnxExit(1) } break } case 'trace': { const traceTarget = rest[0] const traceCommand = rest[1] if (traceTarget === 'anim') { // `debug trace anim on [limit]` // `debug trace anim off [id|all]` // `debug trace anim [limit]` → read samples // `debug trace anim status` | `clear` const sub = rest[1] if (!sub || sub === '--help' || sub === '-h') { console.error( ' usage: rnx debug trace anim > [id|limit]', ) rnxExit(1) } if (sub === 'on') { const raw = rest[2] ?? 'all' const limit = rest[3] ? Number(rest[3]) : undefined const target: number | 'all' = raw === 'all' ? 'all' : Number(raw) if (target !== 'all' && !Number.isFinite(target)) { console.error(` invalid target: ${raw}`) rnxExit(1) } await callTestBridge(bridge, 'enableAnimationTrace', target, limit) const listed = await callTestBridge(bridge, 'listAnimationTraces') console.log(fmt({ enabled: target, traces: listed }, pretty)) break } if (sub === 'off') { const raw = rest[2] ?? 'all' const target: number | 'all' = raw === 'all' ? 'all' : Number(raw) if (target !== 'all' && !Number.isFinite(target)) { console.error(` invalid target: ${raw}`) rnxExit(1) } await callTestBridge(bridge, 'disableAnimationTrace', target) console.log(fmt({ disabled: target }, pretty)) break } if (sub === 'status' || sub === 'clear') { if (sub === 'clear') { await callTestBridge(bridge, 'disableAnimationTrace', 'all') } const listed = await callTestBridge(bridge, 'listAnimationTraces') console.log(fmt({ traces: listed }, pretty)) break } const id = Number(sub) if (!Number.isFinite(id)) { console.error(` invalid id: ${sub}`) rnxExit(1) } const limit = rest[2] ? Number(rest[2]) : undefined const samples = await callTestBridge(bridge, 'getAnimationTrace', id, limit) console.log(fmt({ id, samples }, pretty)) break } if (traceTarget !== 'shell') { console.error(' usage: rnx debug trace [args]') rnxExit(1) } if (!traceCommand || /^[0-9]+$/.test(traceCommand)) { const recentLimit = traceCommand ? Number(traceCommand) : 50 const result = await call(bridge, buildShellTraceRecentCode(recentLimit)) console.log(fmt(result, pretty)) break } if (traceCommand === 'on') { const limit = rest[2] ? Number(rest[2]) : 240 const result = await call(bridge, buildShellTraceControlCode('on', limit)) console.log(fmt(result, pretty)) break } if (traceCommand === 'off') { const result = await call(bridge, buildShellTraceControlCode('off')) console.log(fmt(result, pretty)) break } if (traceCommand === 'clear') { const result = await call(bridge, buildShellTraceControlCode('clear')) console.log(fmt(result, pretty)) break } if (traceCommand === 'status') { const result = await call(bridge, buildShellTraceControlCode('status')) console.log(fmt(result, pretty)) break } console.error( ' usage: rnx debug trace shell [on [limit]|off|status|clear|recentLimit]', ) rnxExit(1) } case 'record': { const target = rest[0] const on = target === 'on' ? 'true' : target === 'off' ? 'false' : 'undefined' const result = await callDebugBridge( bridge, `record(${on})`, args.includes('--host'), ) console.log(fmt({ recording: result }, pretty)) break } case 'recent': { const channel = rest[0] const limit = rest[1] ? Number(rest[1]) : 50 const result = await inspectDebugRecent( bridge, channel, limit, args.includes('--host'), ) console.log(fmt(result, pretty)) break } case 'clear-events': { await callDebugBridge(bridge, 'clearEvents()', args.includes('--host')) console.log(fmt({ cleared: true }, pretty)) break } default: console.error(` unknown subcommand: ${sub}`) printHelp() rnxExit(1) } } catch (err: any) { rethrowIfExit(err) console.error(` debug failed: ${err.message}`) await printBridgeFailureDiagnostics(bridge, { errorsCommand: `rnx get errors 5${simHint}`, warningsCommand: `rnx get warnings 5${simHint}`, requestsCommand: `rnx get requests 5${simHint}`, }) rnxExit(1) } finally { bridge.close() } }