rnxsim 0.1.354 → 0.1.355

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/cli/bridge-flow-runner.ts +44 -51
  2. package/cli/commands/inspect/settling.ts +9 -2
  3. package/cli/outbound-endpoints.ts +1 -1
  4. package/detox/index.ts +144 -74
  5. package/detox/proof-frame.cjs +107 -0
  6. package/dist-cli/bin.js +5 -5
  7. package/dist-cli/chunks/{agent-FB7RPXO6.js → agent-2MW7ZKUX.js} +2 -2
  8. package/dist-cli/chunks/{agent-wrapper-LFU3XEIG.js → agent-wrapper-XYQBKV24.js} +2 -2
  9. package/dist-cli/chunks/{app-fonts-FK6SETRG.js → app-fonts-SXIZFMPY.js} +2 -2
  10. package/dist-cli/chunks/{assert-3KQYZDDK.js → assert-R324GCRG.js} +2 -2
  11. package/dist-cli/chunks/{auth-RFE3J27C.js → auth-INKYLYRZ.js} +2 -2
  12. package/dist-cli/chunks/{beta-XVY4WOYH.js → beta-BQHBSKA5.js} +2 -2
  13. package/dist-cli/chunks/{camera-JROBX5GM.js → camera-UPRFBRPF.js} +2 -2
  14. package/dist-cli/chunks/{chunk-MIK6LMG4.js → chunk-23TG7DLH.js} +1 -1
  15. package/dist-cli/chunks/{chunk-JT4IMNCO.js → chunk-247ZSWTI.js} +2 -2
  16. package/dist-cli/chunks/chunk-2GLEESND.js +535 -0
  17. package/dist-cli/chunks/{chunk-7WANPS3W.js → chunk-2Z2S3C6A.js} +2 -2
  18. package/dist-cli/chunks/{chunk-JJOHJNUQ.js → chunk-42LIUAHU.js} +2 -2
  19. package/dist-cli/chunks/{chunk-UI2IFVHB.js → chunk-6EC3D5IG.js} +1 -1
  20. package/dist-cli/chunks/{chunk-JXNR2KAB.js → chunk-6WCDWBG6.js} +2 -2
  21. package/dist-cli/chunks/{chunk-IGV4KCCM.js → chunk-7URIRES5.js} +2 -2
  22. package/dist-cli/chunks/{chunk-JT435OOM.js → chunk-AY4OA5UI.js} +2 -2
  23. package/dist-cli/chunks/{chunk-FEHHM2LV.js → chunk-BUOAYILA.js} +12 -5
  24. package/dist-cli/chunks/{chunk-N3CE6AZP.js → chunk-CK3M7MAL.js} +2 -2
  25. package/dist-cli/chunks/{chunk-Y4GUWO3X.js → chunk-CRE3V3EG.js} +1 -1
  26. package/dist-cli/chunks/{chunk-YIUIFZAE.js → chunk-CUXVUSZS.js} +2 -2
  27. package/dist-cli/chunks/{chunk-ZNABCRWX.js → chunk-EQEEPP5A.js} +2 -2
  28. package/dist-cli/chunks/{chunk-QRYTCPNO.js → chunk-EZXNGHZT.js} +2 -2
  29. package/dist-cli/chunks/{chunk-LETVUC6M.js → chunk-F6S4Z7N7.js} +1 -1
  30. package/dist-cli/chunks/{chunk-HIYCRDUP.js → chunk-FDI226G4.js} +2 -2
  31. package/dist-cli/chunks/{chunk-X2DJK3TB.js → chunk-FF2CEKZF.js} +2 -2
  32. package/dist-cli/chunks/{chunk-ATQPDEED.js → chunk-FFNFGVGH.js} +2 -2
  33. package/dist-cli/chunks/{chunk-UVIWE3LD.js → chunk-FPWDC2DE.js} +1 -1
  34. package/dist-cli/chunks/{chunk-PHIYWV44.js → chunk-GE7GYFE4.js} +2 -2
  35. package/dist-cli/chunks/{chunk-37S36DFW.js → chunk-GFDFVKVW.js} +1 -1
  36. package/dist-cli/chunks/{chunk-JWXAXCC6.js → chunk-GWL4KMTK.js} +2 -2
  37. package/dist-cli/chunks/{chunk-CKKDXTKV.js → chunk-IAVHAWDL.js} +1 -1
  38. package/dist-cli/chunks/{chunk-4OS3SXPZ.js → chunk-IJGL6T4M.js} +2 -2
  39. package/dist-cli/chunks/{chunk-FPH4TYZO.js → chunk-JE6NXNGL.js} +3 -3
  40. package/dist-cli/chunks/{chunk-OKFSCCFC.js → chunk-JP2YS7LC.js} +1 -1
  41. package/dist-cli/chunks/{chunk-766UIGCS.js → chunk-JYQNEVHV.js} +2 -2
  42. package/dist-cli/chunks/{chunk-B7OMSJXC.js → chunk-KLVSEUJP.js} +2 -2
  43. package/dist-cli/chunks/{chunk-4AQCDL24.js → chunk-LJCBPR7S.js} +2 -2
  44. package/dist-cli/chunks/{chunk-CUK6Y2RR.js → chunk-LQEC53JM.js} +2 -2
  45. package/dist-cli/chunks/{chunk-24DA3Y2Z.js → chunk-MCAQZI3R.js} +1 -1
  46. package/dist-cli/chunks/{chunk-ZJUFI3R7.js → chunk-MKHCGQSF.js} +1 -1
  47. package/dist-cli/chunks/{chunk-GNKXRXND.js → chunk-N532KCKW.js} +2 -2
  48. package/dist-cli/chunks/{chunk-BNXXNT3I.js → chunk-NCF3QNJG.js} +2 -2
  49. package/dist-cli/chunks/{chunk-THFM2CFT.js → chunk-PYSIEEKP.js} +1 -1
  50. package/dist-cli/chunks/{chunk-TJRKLRNP.js → chunk-QWJD2CAJ.js} +1 -1
  51. package/dist-cli/chunks/{chunk-SLD2EMLZ.js → chunk-QYIT7H6Q.js} +2 -2
  52. package/dist-cli/chunks/{chunk-QQNNY43R.js → chunk-S27I7H2K.js} +1 -1
  53. package/dist-cli/chunks/{chunk-EWPX6LEN.js → chunk-S4K3TDYB.js} +2 -2
  54. package/dist-cli/chunks/{chunk-YXBNGSG6.js → chunk-SEWNI4GS.js} +1 -1
  55. package/dist-cli/chunks/{chunk-ENPWD62S.js → chunk-SJEAA2FW.js} +2 -2
  56. package/dist-cli/chunks/{chunk-P4GCLNFM.js → chunk-TADYO7TY.js} +2 -2
  57. package/dist-cli/chunks/chunk-UIBR3GXC.js +4 -0
  58. package/dist-cli/chunks/{chunk-UAYXEP74.js → chunk-VEMZUXVR.js} +1 -1
  59. package/dist-cli/chunks/{chunk-3RW6W3ZO.js → chunk-VWPXXB6U.js} +1 -1
  60. package/dist-cli/chunks/{chunk-IUL2WUWW.js → chunk-W3QLC24Z.js} +2 -2
  61. package/dist-cli/chunks/{chunk-LRVL5MAX.js → chunk-WUYI4PVY.js} +1 -1
  62. package/dist-cli/chunks/{chunk-LC77IVGY.js → chunk-X36BQCH5.js} +2 -2
  63. package/dist-cli/chunks/{chunk-77CBLMD4.js → chunk-XFPGXEKW.js} +1 -1
  64. package/dist-cli/chunks/{chunk-3QLERZYJ.js → chunk-XZJNPATS.js} +2 -2
  65. package/dist-cli/chunks/{chunk-GYGDIT5V.js → chunk-Y3D3DCQV.js} +2 -2
  66. package/dist-cli/chunks/{chunk-LCPZNPIB.js → chunk-Y5BP72QO.js} +1 -1
  67. package/dist-cli/chunks/{chunk-HUU7ZUQK.js → chunk-ZNWLFYZK.js} +2 -2
  68. package/dist-cli/chunks/{chunk-IICFLREN.js → chunk-ZOFRHAGX.js} +1 -1
  69. package/dist-cli/chunks/{chunk-UX6D4X6B.js → chunk-ZQF3PK76.js} +2 -2
  70. package/dist-cli/chunks/{cleanup-F74ISXJC.js → cleanup-5FGGUP6U.js} +2 -2
  71. package/dist-cli/chunks/cli-version-JP47UGY6.js +4 -0
  72. package/dist-cli/chunks/{compat-C3RGYGXL.js → compat-J4OEALGF.js} +2 -2
  73. package/dist-cli/chunks/{config-CDY2RB7Q.js → config-MCH2R7WG.js} +2 -2
  74. package/dist-cli/chunks/{control-J35HAFZA.js → control-NPWFRKZS.js} +2 -2
  75. package/dist-cli/chunks/daemon-M26NJFNC.js +4 -0
  76. package/dist-cli/chunks/{debug-3P2DUTUQ.js → debug-7NHPYUX6.js} +3 -3
  77. package/dist-cli/chunks/{desktop-XIXW34VW.js → desktop-EIBJC5EU.js} +3 -3
  78. package/dist-cli/chunks/{detox-KAKTJ4P4.js → detox-ZFEQKHDT.js} +2 -2
  79. package/dist-cli/chunks/{device-4MAWLZMR.js → device-JM7QMQFQ.js} +2 -2
  80. package/dist-cli/chunks/{diagnose-A3R7HUYA.js → diagnose-DCMU2GAD.js} +2 -2
  81. package/dist-cli/chunks/{disk-cleanup-5R7JYCO6.js → disk-cleanup-RPW5MYGM.js} +2 -2
  82. package/dist-cli/chunks/drivers-DW3CQUTK.js +4 -0
  83. package/dist-cli/chunks/{film-GY5U752Q.js → film-NRGBZSC7.js} +3 -3
  84. package/dist-cli/chunks/flow-76SLGQCI.js +4 -0
  85. package/dist-cli/chunks/help-GUYB5XWH.js +4 -0
  86. package/dist-cli/chunks/{hidden-runtime-alias-HSGN3GN4.js → hidden-runtime-alias-GSZG7QFD.js} +2 -2
  87. package/dist-cli/chunks/{home-paths-BIUN4CBO.js → home-paths-OGXLRRTG.js} +2 -2
  88. package/dist-cli/chunks/inspect-URTBYLXV.js +4 -0
  89. package/dist-cli/chunks/install-desktop-BINBAGSN.js +4 -0
  90. package/dist-cli/chunks/{login-WKNMS4GG.js → login-A7JSAMON.js} +2 -2
  91. package/dist-cli/chunks/{logout-6UFUOESK.js → logout-EBUWNT63.js} +2 -2
  92. package/dist-cli/chunks/{maestro-SJGV7B3W.js → maestro-OEXXVYMW.js} +3 -3
  93. package/dist-cli/chunks/{maestro-generate-PMF2LLKX.js → maestro-generate-DKH6IUTC.js} +3 -3
  94. package/dist-cli/chunks/{mode-MN7LZCLB.js → mode-YKX2UTBX.js} +2 -2
  95. package/dist-cli/chunks/{optional-demo-registry-HFA27YSP.js → optional-demo-registry-W4Z54NYF.js} +2 -2
  96. package/dist-cli/chunks/{perf-XGANSJT3.js → perf-EJDB2NWH.js} +2 -2
  97. package/dist-cli/chunks/{permissions-25UWEJKW.js → permissions-MXUT3YR6.js} +2 -2
  98. package/dist-cli/chunks/privacy-JIJOFIGM.js +4 -0
  99. package/dist-cli/chunks/{record-MA3AI752.js → record-STFUL6CS.js} +3 -3
  100. package/dist-cli/chunks/{report-issue-4DTESWE2.js → report-issue-TE2QTEMZ.js} +2 -2
  101. package/dist-cli/chunks/reset-LAIGONON.js +4 -0
  102. package/dist-cli/chunks/runtime-ZS3FBQLG.js +4 -0
  103. package/dist-cli/chunks/{screenshot-command-JUXMWXS5.js → screenshot-command-SRW3HXF7.js} +3 -3
  104. package/dist-cli/chunks/{screenshot-layers-DJF2E4PE.js → screenshot-layers-IULR35XJ.js} +2 -2
  105. package/dist-cli/chunks/{screenshots-capture-UGZU5GOW.js → screenshots-capture-54GFQLXS.js} +2 -2
  106. package/dist-cli/chunks/{serve-H7GNKJSM.js → serve-WJAOT43K.js} +2 -2
  107. package/dist-cli/chunks/setup-36ACJ5FT.js +4 -0
  108. package/dist-cli/chunks/{skills-4JGPOSFE.js → skills-DW5GLHAT.js} +2 -2
  109. package/dist-cli/chunks/{state-DEAQFKVP.js → state-IW4THIPU.js} +2 -2
  110. package/dist-cli/chunks/{storage-Y6F5NTGH.js → storage-F4RQZFTZ.js} +2 -2
  111. package/dist-cli/chunks/store-MTN4TPL6.js +4 -0
  112. package/dist-cli/chunks/telemetry-CJSVYHLI.js +4 -0
  113. package/dist-cli/chunks/{timeline-HOHYYDOC.js → timeline-IX3HVS22.js} +2 -2
  114. package/dist-cli/chunks/{upgrade-QF5YHLSG.js → upgrade-T4LWQ2JA.js} +2 -2
  115. package/dist-cli/chunks/upload-LHWECFWH.js +4 -0
  116. package/dist-cli/chunks/{version-PDCKYJLB.js → version-MNRMHJ64.js} +2 -2
  117. package/dist-cli/chunks/{web-C5EUAFFW.js → web-ARKJI3PY.js} +2 -2
  118. package/dist-cli/chunks/welcome-XH657K2V.js +5 -0
  119. package/dist-cli/chunks/{what-happened-L56ZFIMJ.js → what-happened-EBK5NZFI.js} +2 -2
  120. package/dist-lib/agent-daemon-client.cjs +1 -1
  121. package/dist-lib/agent-events.cjs +1 -1
  122. package/dist-lib/agent-identity.cjs +1 -1
  123. package/dist-lib/agent-sessions.cjs +1 -1
  124. package/dist-lib/attached-projects.cjs +1 -1
  125. package/dist-lib/auth/shared-session.cjs +1 -1
  126. package/dist-lib/backend-origin.cjs +1 -1
  127. package/dist-lib/beta.cjs +1 -1
  128. package/dist-lib/beta.mjs +1 -1
  129. package/dist-lib/bridge-constants.cjs +1 -1
  130. package/dist-lib/bridge-contract.cjs +1 -1
  131. package/dist-lib/cli-constants.cjs +1 -1
  132. package/dist-lib/config.cjs +1 -1
  133. package/dist-lib/detox/index.cjs +196 -54
  134. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  135. package/dist-lib/home-paths.cjs +1 -1
  136. package/dist-lib/host/bridge-host.cjs +1 -1
  137. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  138. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  139. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  140. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  141. package/dist-lib/host/websocket-proxy.cjs +1 -1
  142. package/dist-lib/index.cjs +10 -3
  143. package/dist-lib/jump-to-source-babel.cjs +1 -1
  144. package/dist-lib/menu.cjs +1 -1
  145. package/dist-lib/menu.mjs +1 -1
  146. package/dist-lib/metro-production-bundle.cjs +1 -1
  147. package/dist-lib/metro-production-bundle.mjs +1 -1
  148. package/dist-lib/metro.cjs +1 -1
  149. package/dist-lib/profiles.cjs +1 -1
  150. package/dist-lib/public-brand.cjs +1 -1
  151. package/dist-lib/react-native-host-modules.cjs +1 -1
  152. package/dist-lib/react-native-host-modules.mjs +1 -1
  153. package/dist-lib/render-mode.cjs +1 -1
  154. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  155. package/dist-lib/sdk.cjs +10 -3
  156. package/dist-lib/sdk.mjs +10 -3
  157. package/dist-lib/skills.cjs +4735 -196
  158. package/dist-lib/vite.cjs +1 -1
  159. package/package.json +1 -1
  160. package/dist-cli/chunks/chunk-BAWV2UEF.js +0 -543
  161. package/dist-cli/chunks/chunk-YATZ7U4S.js +0 -4
  162. package/dist-cli/chunks/cli-version-VSDCFXXN.js +0 -4
  163. package/dist-cli/chunks/daemon-DLLEODJY.js +0 -4
  164. package/dist-cli/chunks/drivers-BB3BRKDS.js +0 -4
  165. package/dist-cli/chunks/flow-ALMSRBVL.js +0 -4
  166. package/dist-cli/chunks/help-3ZL7MRUX.js +0 -4
  167. package/dist-cli/chunks/inspect-RCU7XVAO.js +0 -4
  168. package/dist-cli/chunks/install-desktop-LYVKLTCU.js +0 -4
  169. package/dist-cli/chunks/privacy-SIDMP5EF.js +0 -4
  170. package/dist-cli/chunks/reset-GPHG5XN3.js +0 -4
  171. package/dist-cli/chunks/runtime-VOEIOODA.js +0 -4
  172. package/dist-cli/chunks/setup-G2WYLPM6.js +0 -4
  173. package/dist-cli/chunks/store-3EQT7VU6.js +0 -4
  174. package/dist-cli/chunks/telemetry-FANCQHKG.js +0 -4
  175. package/dist-cli/chunks/upload-QKJHFAX7.js +0 -4
  176. package/dist-cli/chunks/welcome-3CQ2GE4F.js +0 -5
@@ -2,6 +2,10 @@ import * as fs from 'fs'
2
2
  import * as path from 'path'
3
3
  import { devices, type DeviceModel } from 'sootsim-engine/settings'
4
4
  import yaml from 'yaml'
5
+ import {
6
+ REQUIRED_IDENTICAL_PROOF_FRAMES,
7
+ framesIdentical,
8
+ } from '../detox/proof-frame.cjs'
5
9
  import { composeFramedScreenshot } from '../src/screenshots/frame-compose'
6
10
  import { resetGuestAppStateEval } from './app-state-reset'
7
11
  import {
@@ -415,7 +419,6 @@ export class SootSimBridgeFlowRunner {
415
419
  private lastRecordingDurationMs: number | null = null
416
420
  private lastRecordingFrameStats: unknown = null
417
421
  private flowTraceSteps: SootSimFlowTraceStep[] = []
418
- private lastVisualSettledAtMs = 0
419
422
  private simRouteHint: string | null = null
420
423
  // set by runStep when a step throws — lets the outer flow command
421
424
  // dump a rich per-step failure bundle (screenshot + describe.json +
@@ -1489,23 +1492,6 @@ export class SootSimBridgeFlowRunner {
1489
1492
  layers?: 'full' | 'tenant' | 'shell'
1490
1493
  },
1491
1494
  ): Promise<string> {
1492
- // give transitions + last-moment data paint a brief settle window before
1493
- // freezing the frame. screenshot flows care more about visual completeness
1494
- // than minimum latency. waitForVisualSettle drains in-flight animations
1495
- // (it returns immediately if nothing is animating), then we hold for an
1496
- // additional 250ms paint window for fade-ins / image decodes / etc.
1497
- // before this change, a 250ms sleep was the *only* settle window — flows
1498
- // that took a screenshot at the tail of a screen transition would catch
1499
- // a half-finished frame on slow machines.
1500
- if (!this.hasFreshVisualSettle()) {
1501
- try {
1502
- await this.waitForVisualSettle()
1503
- await sleep(250)
1504
- } catch {
1505
- // settling helper can throw on disconnected sims; fall through so a
1506
- // stale sim still produces *some* screenshot.
1507
- }
1508
- }
1509
1495
  // flow `takeScreenshot: <name>` may be a plain name ("hero") or a full
1510
1496
  // relative path with slashes ("apps/foo/screenshots/en/02-forum") —
1511
1497
  // maestro flows commonly use the latter. mkdir the parent of the
@@ -1531,9 +1517,32 @@ export class SootSimBridgeFlowRunner {
1531
1517
  simId: this.opts.simId,
1532
1518
  }
1533
1519
  if (layers && layers !== 'full') screenshotRequest.layers = layers
1534
- const dataUrl: string = await this.bridge.send(screenshotRequest)
1535
- const base64 = dataUrl.replace(/^data:image\/png;base64,/, '')
1536
- const rawBuffer = Buffer.from(base64, 'base64')
1520
+ let previousBuffer: Buffer | null = null
1521
+ let identicalFrames = 1
1522
+ let rawBuffer: Buffer | null = null
1523
+ for (let attempt = 0; attempt < 12; attempt++) {
1524
+ await this.waitForVisualSettle()
1525
+ const dataUrl: string = await this.bridge.send(screenshotRequest)
1526
+ const current = Buffer.from(
1527
+ dataUrl.replace(/^data:image\/png;base64,/, ''),
1528
+ 'base64',
1529
+ )
1530
+ if (framesIdentical(previousBuffer, current)) {
1531
+ identicalFrames++
1532
+ if (identicalFrames >= REQUIRED_IDENTICAL_PROOF_FRAMES) {
1533
+ rawBuffer = current
1534
+ break
1535
+ }
1536
+ } else {
1537
+ identicalFrames = 1
1538
+ }
1539
+ previousBuffer = current
1540
+ }
1541
+ if (!rawBuffer) {
1542
+ throw new Error(
1543
+ `screenshot did not reach ${REQUIRED_IDENTICAL_PROOF_FRAMES} identical idle frames`,
1544
+ )
1545
+ }
1537
1546
  if (spec.withFrame) {
1538
1547
  const model = await this.readCurrentDeviceModel()
1539
1548
  if (!model) {
@@ -1563,13 +1572,7 @@ export class SootSimBridgeFlowRunner {
1563
1572
  }
1564
1573
 
1565
1574
  async captureScreenshot(outputPath: string) {
1566
- fs.mkdirSync(path.dirname(outputPath), { recursive: true })
1567
- const dataUrl: string = await this.bridge.send({
1568
- type: 'screenshot',
1569
- simId: this.opts.simId,
1570
- })
1571
- const base64 = dataUrl.replace(/^data:image\/png;base64,/, '')
1572
- fs.writeFileSync(outputPath, Buffer.from(base64, 'base64'))
1575
+ await this.takeScreenshot({ path: outputPath })
1573
1576
  }
1574
1577
 
1575
1578
  // writes a per-failure debug bundle: screenshot + structured describe
@@ -1880,7 +1883,9 @@ export class SootSimBridgeFlowRunner {
1880
1883
  stablePolls: 2,
1881
1884
  strict: true,
1882
1885
  })
1883
- if (result.settled) this.markVisualSettled()
1886
+ if (!result.settled) {
1887
+ throw new Error(`animation did not settle within ${maxMs}ms`)
1888
+ }
1884
1889
  }
1885
1890
 
1886
1891
  async back() {
@@ -2082,31 +2087,19 @@ export class SootSimBridgeFlowRunner {
2082
2087
  }
2083
2088
 
2084
2089
  private async waitForVisualSettle() {
2085
- try {
2086
- await this.evaluate(`new Promise((resolve) => {
2087
- requestAnimationFrame(() => {
2088
- requestAnimationFrame(() => {
2089
- requestAnimationFrame(() => {
2090
- setTimeout(resolve, 180)
2091
- })
2092
- })
2093
- })
2094
- })`)
2095
- this.markVisualSettled()
2096
- } catch {
2097
- await sleep(180)
2098
- this.markVisualSettled()
2090
+ const result = await waitForSootsimIdle({
2091
+ bridge: this.bridge,
2092
+ simId: this.opts.simId,
2093
+ maxMs: 10_000,
2094
+ pollMs: 32,
2095
+ stablePolls: 3,
2096
+ strict: true,
2097
+ })
2098
+ if (!result.settled) {
2099
+ throw new Error(`visual state did not settle within ${result.elapsed}ms`)
2099
2100
  }
2100
2101
  }
2101
2102
 
2102
- private markVisualSettled() {
2103
- this.lastVisualSettledAtMs = Date.now()
2104
- }
2105
-
2106
- private hasFreshVisualSettle() {
2107
- return Date.now() - this.lastVisualSettledAtMs < 750
2108
- }
2109
-
2110
2103
  private resolveShellLaunchAppId(opts: unknown, state: ShellState | null): string {
2111
2104
  if (typeof opts === 'string' && opts.length > 0) return opts
2112
2105
  if (isObjectRecord(opts)) {
@@ -58,16 +58,21 @@ export async function waitForSootsimIdle({
58
58
 
59
59
  const readSnapshot = async () => {
60
60
  let animating = false
61
+ let layoutDirty = false
61
62
  let pendingFetches = 0
62
63
  let requestTotal = 0
63
64
  let requestInFlight = 0
65
+ let renderStatsAvailable = false
64
66
  try {
65
67
  const stats = await window.__sootsimRenderHost?.queryStats?.()
66
68
  if (stats) {
69
+ renderStatsAvailable = true
67
70
  animating =
68
71
  stats.hasActiveAnims === true ||
69
72
  stats.hasActiveNativeAnimations === true ||
70
73
  stats.hasPendingAnimationFrames === true
74
+ layoutDirty =
75
+ stats.layoutDirty === true && stats.renderRequested === true
71
76
  // a freshly-pushed screen showing a skeleton is layout-stable
72
77
  // but not actually settled — its data/images are still in
73
78
  // flight. treat bounded image-loader fetches as not-idle so
@@ -94,7 +99,7 @@ export async function waitForSootsimIdle({
94
99
  }
95
100
  walk(root)
96
101
  }
97
- return { layout: nodes.join(';'), animating, pendingFetches, requestTotal, requestInFlight }
102
+ return { layout: nodes.join(';'), animating, layoutDirty, pendingFetches, renderStatsAvailable, requestTotal, requestInFlight }
98
103
  }
99
104
 
100
105
  // how long generic background network is allowed to keep us waiting AFTER
@@ -114,7 +119,9 @@ export async function waitForSootsimIdle({
114
119
  let visuallyStillSince = 0
115
120
  while (Date.now() < deadline) {
116
121
  const snapshot = await readSnapshot()
117
- const strictOk = !strict || !snapshot.animating
122
+ const strictOk =
123
+ !strict ||
124
+ (snapshot.renderStatsAvailable && !snapshot.animating && !snapshot.layoutDirty)
118
125
  // visually settled: no animations, no image content loading, layout
119
126
  // unchanged since the last poll.
120
127
  const visuallyStill =
@@ -125,7 +125,7 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
125
125
  count: 3,
126
126
  },
127
127
  'packages/sootsim-engine/src/components/native-ui/LottieHost.tsx :: fetch :: descriptor.value':
128
- { category: 'guest_app_network', count: 1 },
128
+ { category: 'guest_app_network', count: 2 },
129
129
  'packages/sootsim-engine/src/demo/demo-share-register.ts :: fetch :: shareUrl': {
130
130
  category: 'guest_app_network',
131
131
  count: 1,
package/detox/index.ts CHANGED
@@ -6,6 +6,7 @@ import * as fs from 'fs'
6
6
  import * as path from 'path'
7
7
  import { chromium } from 'playwright'
8
8
  import { launchReapedChromeScoped } from '../../../scripts/lib/reap-browser'
9
+ import { pollUntil } from '../src/poll-until'
9
10
  import { createExpect, createWaitFor } from './expectations'
10
11
  import {
11
12
  dispatchTwoFingerGesture,
@@ -22,6 +23,7 @@ import {
22
23
  type MotionChangeDefinition,
23
24
  type MotionChangeMeasurement,
24
25
  } from './motion-change.cjs'
26
+ import { REQUIRED_IDENTICAL_PROOF_FRAMES, framesIdentical } from './proof-frame.cjs'
25
27
  import type { SootSimElement } from './element-types'
26
28
  import type { Browser, BrowserContext, Page } from 'playwright'
27
29
  import type { SootsimEventMap, SootsimEventName } from 'sootsim-engine/sootsim-event'
@@ -42,6 +44,7 @@ let _reapBrowserNow: (() => void) | null = null
42
44
  let _context: BrowserContext | null = null
43
45
  let _page: Page | null = null
44
46
  let _synchronizationEnabled = false
47
+ let _suspendedAppId: string | null = null
45
48
  const pageDiagnostics = new WeakMap<Page, string[]>()
46
49
 
47
50
  const STATUS_BAR_OVERRIDE_EVENT = 'sootsim:statusBarOverride'
@@ -339,9 +342,14 @@ async function closeBrowser() {
339
342
  }
340
343
  }
341
344
 
342
- export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void> {
345
+ export async function waitForSootsimIdle(
346
+ page: Page,
347
+ maxMs = 3000,
348
+ strict = false,
349
+ ): Promise<void> {
343
350
  const result = await page.evaluate(
344
- async ({ maxMs }) => {
351
+ async ({ maxMs, strict }) => {
352
+ const start = Date.now()
345
353
  let transitionError: string | null = null
346
354
  try {
347
355
  const waitForScreenTransitions = (window as any).__sootsimTest
@@ -352,13 +360,6 @@ export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void
352
360
  settleMs: 48,
353
361
  startWindowMs: 600,
354
362
  })
355
- if (
356
- transitionResult?.started === true &&
357
- transitionResult.settled === true &&
358
- transitionResult.timedOut !== true
359
- ) {
360
- return { settled: true, elapsed: transitionResult.waitedMs ?? 0 }
361
- }
362
363
  if (transitionResult?.timedOut) {
363
364
  return {
364
365
  settled: false,
@@ -371,7 +372,6 @@ export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void
371
372
  transitionError = error instanceof Error ? error.message : String(error)
372
373
  }
373
374
 
374
- const start = Date.now()
375
375
  const deadline = start + maxMs
376
376
  const pollMs = 50
377
377
  const requiredStablePolls = 3
@@ -388,6 +388,28 @@ export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void
388
388
  const readSnapshot = async () => {
389
389
  const root = (window as any).__sootsimRoot
390
390
  const layout: number[] = []
391
+ let animating = false
392
+ let auxSurfaceDebug: unknown = null
393
+ let layoutDirty = false
394
+ let renderStatsAvailable = false
395
+ if (strict) {
396
+ try {
397
+ const renderHost: unknown = Reflect.get(window, '__sootsimRenderHost')
398
+ const queryStats =
399
+ typeof renderHost === 'object' && renderHost !== null
400
+ ? Reflect.get(renderHost, 'queryStats')
401
+ : null
402
+ const stats =
403
+ typeof queryStats === 'function' ? await queryStats.call(renderHost) : null
404
+ renderStatsAvailable = !!stats
405
+ auxSurfaceDebug = stats?.auxSurfaceDebug ?? null
406
+ animating =
407
+ stats?.hasActiveAnims === true ||
408
+ stats?.hasActiveNativeAnimations === true ||
409
+ stats?.hasPendingAnimationFrames === true
410
+ layoutDirty = stats?.layoutDirty === true && stats?.renderRequested === true
411
+ } catch {}
412
+ }
391
413
  if (root) {
392
414
  const walk = (node: any) => {
393
415
  if (node.layout && node.layout.width > 0) {
@@ -402,20 +424,33 @@ export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void
402
424
  }
403
425
  walk(root)
404
426
  }
405
- return layout
427
+ return {
428
+ animating,
429
+ auxSurfaceDebug,
430
+ layout,
431
+ layoutDirty,
432
+ renderStatsAvailable,
433
+ }
406
434
  }
407
435
 
408
436
  let stableLayout: number[] | null = null
409
437
  let stable = 0
438
+ let lastSnapshot: Awaited<ReturnType<typeof readSnapshot>> | null = null
410
439
  while (Date.now() < deadline) {
411
- const layout = await readSnapshot()
412
- if (isLayoutStable(stableLayout, layout)) {
440
+ const snapshot = await readSnapshot()
441
+ lastSnapshot = snapshot
442
+ if (
443
+ (!strict || snapshot.renderStatsAvailable) &&
444
+ !snapshot.animating &&
445
+ !snapshot.layoutDirty &&
446
+ isLayoutStable(stableLayout, snapshot.layout)
447
+ ) {
413
448
  stable++
414
449
  if (stable >= requiredStablePolls) {
415
450
  return { settled: true, elapsed: Date.now() - start }
416
451
  }
417
452
  } else {
418
- stableLayout = layout
453
+ stableLayout = snapshot.layout
419
454
  stable = 0
420
455
  }
421
456
  await sleep(pollMs)
@@ -425,10 +460,12 @@ export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void
425
460
  elapsed: Date.now() - start,
426
461
  reason: transitionError
427
462
  ? `layout fallback after screen transition wait failed: ${transitionError}`
428
- : 'layout fallback',
463
+ : strict
464
+ ? `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)}`
465
+ : 'layout fallback',
429
466
  }
430
467
  },
431
- { maxMs },
468
+ { maxMs, strict },
432
469
  )
433
470
 
434
471
  if (!result?.settled) {
@@ -633,7 +670,7 @@ function writeFastScreenshotFrames<T extends FastScreenshotData>(
633
670
 
634
671
  // single-shot canvas capture — no frame-stability poll. used for multi-stage
635
672
  // animation captures where we explicitly want to sample mid-transition. the
636
- // usual captureSootsimPng polls until two consecutive frames are byte-equal
673
+ // usual captureSootsimPng requires three consecutive idle frames to be byte-equal
637
674
  // which is ideal for settled screenshots but blocks for the full timeout
638
675
  // while an animation is running.
639
676
  async function captureSootsimPngFast(opts?: {
@@ -661,62 +698,25 @@ async function captureSootsimPng(opts?: {
661
698
  crop?: { h: number; w: number; x: number; y: number }
662
699
  }) {
663
700
  const page = getPage()
664
- // frame-stability poll: capture twice with a gap > one tenant live-blur
665
- // push interval (~67ms) and accept only when both PNGs are byte-equal.
666
- // PNGs from canvas.toDataURL are deterministic for stable RGBA, so byte-
667
- // equal = pixel-equal. without this, the keyboard's blur stream and the
668
- // text-input cursor blink produce 1-12pp frame-to-frame variance in
669
- // captures fired blindly after a settle, which then drifts the published
670
- // proof manifest. matches the same poll in scripts/conformance-fast-iter.ts
671
- // so cron and publish lock onto the same stable frame.
672
- const result: {
673
- dataUrl: string
674
- stable: boolean
675
- attempts: number
676
- elapsedMs: number
677
- } = await page.evaluate(
678
- async (captureOpts) => {
679
- const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
680
- .SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
681
- if (typeof screenshot !== 'function') {
682
- throw new Error('sootsim screenshot bridge is not installed')
683
- }
684
- const gapMs = 100
685
- const maxWaitMs = 8000
686
- const minAttempts = 3
687
- const t0 = Date.now()
688
- let prev = await screenshot(captureOpts)
689
- let attempts = 1
690
- while (attempts < minAttempts || Date.now() - t0 < maxWaitMs) {
691
- await new Promise((r) => setTimeout(r, gapMs))
692
- const next = await screenshot(captureOpts)
693
- attempts++
694
- if (next === prev) {
695
- return {
696
- dataUrl: next,
697
- stable: true,
698
- attempts,
699
- elapsedMs: Date.now() - t0,
700
- }
701
- }
702
- prev = next
703
- }
704
- return { dataUrl: prev, stable: false, attempts, elapsedMs: Date.now() - t0 }
705
- },
706
- {
707
- crop: opts?.crop,
708
- format: 'png',
709
- },
710
- )
711
- if (!result.dataUrl) {
712
- throw new Error('sootsim screenshot bridge returned an empty image')
713
- }
714
- if (!result.stable) {
715
- console.warn(
716
- `[sootsim-detox] screenshot did not stabilize after ${result.elapsedMs}ms (${result.attempts} captures); using last frame`,
717
- )
701
+ const deadline = Date.now() + 8000
702
+ let previous: Buffer | null = null
703
+ let identicalFrames = 1
704
+ let attempts = 0
705
+ while (Date.now() < deadline) {
706
+ await waitForSootsimIdle(page, Math.max(1, deadline - Date.now()), true)
707
+ const current = await captureSootsimPngFast(opts)
708
+ attempts++
709
+ if (framesIdentical(previous, current)) {
710
+ identicalFrames++
711
+ if (identicalFrames >= REQUIRED_IDENTICAL_PROOF_FRAMES) return current
712
+ } else {
713
+ identicalFrames = 1
714
+ }
715
+ previous = current
718
716
  }
719
- return decodePngDataUrl(result.dataUrl)
717
+ throw new Error(
718
+ `sootsim screenshot did not reach ${REQUIRED_IDENTICAL_PROOF_FRAMES} identical idle frames in ${attempts} captures`,
719
+ )
720
720
  }
721
721
 
722
722
  async function writeSootsimScreenshot(
@@ -892,8 +892,23 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
892
892
 
893
893
  async replaceText(text: string) {
894
894
  const page = getPage()
895
- await focusElementForTextEntry(el)
896
895
  const node = await findNodeByMatcher(matcher)
896
+ if (
897
+ matcher.type === 'id' &&
898
+ typeof node?.nodeId === 'number' &&
899
+ node.nodeId >= 0x40000000
900
+ ) {
901
+ const replaced = await page.evaluate(
902
+ async ({ id, value }) => {
903
+ const mainShell = window.SootSim?.bridges?.mainShell
904
+ if (typeof mainShell?.callTestBridge !== 'function') return false
905
+ return mainShell.callTestBridge('replaceTextByTestId', id, value)
906
+ },
907
+ { id: matcher.value, value: text },
908
+ )
909
+ if (replaced === true) return
910
+ }
911
+ await focusElementForTextEntry(el)
897
912
  const value = typeof node?.text === 'string' ? node.text : ''
898
913
  for (let i = 0; i < value.length; i++) {
899
914
  await page.keyboard.press('Backspace')
@@ -1267,6 +1282,40 @@ export const device = {
1267
1282
  // toggled on an existing context.
1268
1283
  recordVideoDir?: string
1269
1284
  }) {
1285
+ if (
1286
+ _page &&
1287
+ opts?.newInstance === false &&
1288
+ !opts.delete &&
1289
+ !opts.recordVideoDir &&
1290
+ _suspendedAppId
1291
+ ) {
1292
+ const page = _page
1293
+ const appId = _suspendedAppId
1294
+ await page.evaluate(
1295
+ (id) => window.SootSim?.bridges?.mainShell?.launchApp?.(id),
1296
+ appId,
1297
+ )
1298
+ await pollUntil(
1299
+ () =>
1300
+ page.evaluate(async (id) => {
1301
+ const state = await window.SootSim?.bridges?.mainShell?.getState?.()
1302
+ return (
1303
+ state?.state === 'app' &&
1304
+ state.activeApp === id &&
1305
+ state.switcherPhase === 'idle'
1306
+ )
1307
+ }, appId),
1308
+ Boolean,
1309
+ {
1310
+ timeoutMs: 10000,
1311
+ label: `SootSim app ${appId} to resume`,
1312
+ },
1313
+ )
1314
+ _suspendedAppId = null
1315
+ await waitForSootsimTree(page, 10000)
1316
+ return
1317
+ }
1318
+
1270
1319
  if (!_browser) {
1271
1320
  // Conformance renders CanvasKit and must use the real platform GPU. The
1272
1321
  // bundled chromium_headless_shell has no GPU and silently falls back to
@@ -2237,7 +2286,28 @@ export const device = {
2237
2286
  },
2238
2287
 
2239
2288
  async sendToHome() {
2240
- // no-op
2289
+ const page = getPage()
2290
+ _suspendedAppId = await page.evaluate(async () => {
2291
+ const state = await window.SootSim?.bridges?.mainShell?.getState?.()
2292
+ const appId = state?.activeApp
2293
+ if (typeof appId !== 'string') {
2294
+ throw new Error(`cannot send an inactive SootSim app home`)
2295
+ }
2296
+ window.SootSim?.bridges?.mainShell?.goHome?.()
2297
+ return appId
2298
+ })
2299
+ await pollUntil(
2300
+ () =>
2301
+ page.evaluate(async () => {
2302
+ const state = await window.SootSim?.bridges?.mainShell?.getState?.()
2303
+ return state?.state === 'home' && state.switcherPhase === 'idle'
2304
+ }),
2305
+ Boolean,
2306
+ {
2307
+ timeoutMs: 10000,
2308
+ label: 'SootSim home transition',
2309
+ },
2310
+ )
2241
2311
  },
2242
2312
 
2243
2313
  // get direct access to the playwright page for advanced use
@@ -0,0 +1,107 @@
1
+ // proof captures reserve the top and bottom of a device frame for platform
2
+ // chrome. those pixels can be fully opaque while the app surface is blank or
3
+ // still fading in, so evidence density is measured only in the content band.
4
+ const PROOF_CONTENT_DEFINITION = Object.freeze({
5
+ channelThreshold: 24,
6
+ contentBandInsetFraction: 0.08,
7
+ minimumContentPercent: 0.005,
8
+ })
9
+
10
+ const REQUIRED_IDENTICAL_PROOF_FRAMES = 3
11
+
12
+ function framesIdentical(a, b) {
13
+ if (!a || !b || a.length !== b.length) return false
14
+ return a.equals(b)
15
+ }
16
+
17
+ function measureProofFrameContent(input) {
18
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
19
+ throw new Error('proof content measurement requires an input object')
20
+ }
21
+ const { data, width, height } = input
22
+ if (
23
+ !data ||
24
+ typeof data.length !== 'number' ||
25
+ !Number.isInteger(width) ||
26
+ !Number.isInteger(height) ||
27
+ width <= 0 ||
28
+ height <= 0 ||
29
+ data.length !== width * height * 4
30
+ ) {
31
+ throw new Error('proof content measurement needs RGBA pixels and dimensions')
32
+ }
33
+
34
+ const yStart = Math.floor(height * PROOF_CONTENT_DEFINITION.contentBandInsetFraction)
35
+ const yEnd = Math.ceil(
36
+ height * (1 - PROOF_CONTENT_DEFINITION.contentBandInsetFraction),
37
+ )
38
+ const bucketCounts = new Uint32Array(32 * 32 * 32)
39
+ let totalPixels = 0
40
+ for (let y = yStart; y < yEnd; y++) {
41
+ for (let x = 0; x < width; x++) {
42
+ const offset = (y * width + x) * 4
43
+ const bucket =
44
+ ((data[offset] >> 3) << 10) |
45
+ ((data[offset + 1] >> 3) << 5) |
46
+ (data[offset + 2] >> 3)
47
+ bucketCounts[bucket]++
48
+ totalPixels++
49
+ }
50
+ }
51
+
52
+ let dominantBucket = 0
53
+ for (let index = 1; index < bucketCounts.length; index++) {
54
+ if (bucketCounts[index] > bucketCounts[dominantBucket]) dominantBucket = index
55
+ }
56
+ const dominant = {
57
+ red: (((dominantBucket >> 10) & 31) << 3) + 4,
58
+ green: (((dominantBucket >> 5) & 31) << 3) + 4,
59
+ blue: ((dominantBucket & 31) << 3) + 4,
60
+ }
61
+
62
+ let contentPixels = 0
63
+ for (let y = yStart; y < yEnd; y++) {
64
+ for (let x = 0; x < width; x++) {
65
+ const offset = (y * width + x) * 4
66
+ if (
67
+ Math.max(
68
+ Math.abs(data[offset] - dominant.red),
69
+ Math.abs(data[offset + 1] - dominant.green),
70
+ Math.abs(data[offset + 2] - dominant.blue),
71
+ ) >= PROOF_CONTENT_DEFINITION.channelThreshold
72
+ ) {
73
+ contentPixels++
74
+ }
75
+ }
76
+ }
77
+
78
+ const contentPercent = (contentPixels / totalPixels) * 100
79
+ return {
80
+ contentPercent,
81
+ contentPixels,
82
+ dominant,
83
+ passedMinimum:
84
+ contentPercent >= PROOF_CONTENT_DEFINITION.minimumContentPercent,
85
+ totalPixels,
86
+ }
87
+ }
88
+
89
+ function settleDecision(previousPixels, currentFrame) {
90
+ if (!currentFrame) return { decision: 'continue', content: null }
91
+ const content = measureProofFrameContent(currentFrame)
92
+ if (!content.passedMinimum) {
93
+ return { decision: 'insufficient-content', content }
94
+ }
95
+ if (previousPixels && framesIdentical(previousPixels, currentFrame.data)) {
96
+ return { decision: 'identical', content }
97
+ }
98
+ return { decision: 'changed', content }
99
+ }
100
+
101
+ module.exports = {
102
+ PROOF_CONTENT_DEFINITION,
103
+ REQUIRED_IDENTICAL_PROOF_FRAMES,
104
+ framesIdentical,
105
+ measureProofFrameContent,
106
+ settleDecision,
107
+ }