peachsim 0.1.578 → 0.1.579

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 (178) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +11 -9
  3. package/cli/app-catalog.ts +77 -0
  4. package/cli/app-fonts.ts +4 -20
  5. package/cli/app-splash.ts +2 -18
  6. package/cli/bridge-flow-runner.ts +505 -139
  7. package/cli/cloud-client.ts +1 -4
  8. package/cli/cloud-session.ts +1 -4
  9. package/cli/cloud-test-result-files.ts +3 -0
  10. package/cli/cloud-test-tunnel.ts +2 -5
  11. package/cli/command-registry.ts +2 -0
  12. package/cli/commands/agent.ts +149 -23
  13. package/cli/commands/assert.ts +1 -4
  14. package/cli/commands/box/peach-command.ts +62 -0
  15. package/cli/commands/box/plane-share.ts +0 -70
  16. package/cli/commands/box/rnx-command.ts +1 -60
  17. package/cli/commands/box/search-command.ts +14 -0
  18. package/cli/commands/box.ts +2 -2
  19. package/cli/commands/control.ts +121 -27
  20. package/cli/commands/cpu-profile.ts +23 -10
  21. package/cli/commands/daemon.ts +1 -9
  22. package/cli/commands/detox.ts +26 -18
  23. package/cli/commands/device.ts +129 -1
  24. package/cli/commands/film.ts +25 -16
  25. package/cli/commands/flow.ts +9 -2
  26. package/cli/commands/inspect/actions.ts +75 -1
  27. package/cli/commands/inspect/core.ts +81 -2
  28. package/cli/commands/inspect/describe.ts +2 -6
  29. package/cli/commands/inspect/env.ts +1 -1
  30. package/cli/commands/inspect/settling.ts +20 -3
  31. package/cli/commands/inspect/shared.ts +1 -4
  32. package/cli/commands/inspect/sleep.ts +1 -1
  33. package/cli/commands/inspect.ts +16 -49
  34. package/cli/commands/install-desktop.ts +8 -6
  35. package/cli/commands/login.ts +3 -5
  36. package/cli/commands/maestro-generate.ts +1 -4
  37. package/cli/commands/maestro.ts +38 -21
  38. package/cli/commands/no-bridge-hint.ts +0 -14
  39. package/cli/commands/permissions.ts +2 -2
  40. package/cli/commands/platform.ts +13 -6
  41. package/cli/commands/screenshot.ts +6 -1
  42. package/cli/commands/skills.ts +1 -1
  43. package/cli/commands/test.ts +6 -2
  44. package/cli/commands/three-mode.ts +162 -19
  45. package/cli/commands/upload.ts +3 -18
  46. package/cli/current-sim.ts +0 -1
  47. package/cli/desktop-companion.ts +40 -8
  48. package/cli/drivers/playwright-provisioning.ts +1 -4
  49. package/cli/flow-export.ts +1 -8
  50. package/cli/flow-session.ts +0 -4
  51. package/cli/hints.ts +1 -16
  52. package/cli/inspect-notice-state.ts +0 -4
  53. package/cli/main.ts +2 -4
  54. package/cli/outbound-endpoints.ts +1 -24
  55. package/cli/parse-args.ts +1 -2
  56. package/cli/rnx-in-process.ts +3 -1
  57. package/cli/run-rnx-core.ts +4 -2
  58. package/cli/run-rnx.ts +18 -10
  59. package/cli/send-to-box.ts +1 -4
  60. package/cli/telemetry.ts +1 -1
  61. package/cli/ws-bridge.ts +1 -5
  62. package/detox/gestures.ts +20 -6
  63. package/detox/index.ts +379 -123
  64. package/detox/navigation-lifecycle.ts +22 -13
  65. package/dist-lib/agent-daemon-client.cjs +21 -15
  66. package/dist-lib/agent-events.cjs +1 -1
  67. package/dist-lib/agent-identity.cjs +3 -17
  68. package/dist-lib/agent-sessions.cjs +5 -4
  69. package/dist-lib/attached-projects.cjs +1 -77
  70. package/dist-lib/auth/shared-session.cjs +1 -1
  71. package/dist-lib/backend-origin.cjs +11 -1
  72. package/dist-lib/beta.cjs +1 -1
  73. package/dist-lib/beta.mjs +1 -1
  74. package/dist-lib/bridge-constants.cjs +1 -1
  75. package/dist-lib/bridge-contract-input.cjs +7 -5
  76. package/dist-lib/bridge-contract-input.mjs +7 -5
  77. package/dist-lib/bridge-contract.cjs +1 -1
  78. package/dist-lib/bridge-contract.mjs +1 -1
  79. package/dist-lib/capture-contract.cjs +30 -7
  80. package/dist-lib/capture-contract.mjs +30 -7
  81. package/dist-lib/cli-constants.cjs +1 -1
  82. package/dist-lib/cloud-contract.cjs +1 -1
  83. package/dist-lib/cloud-contract.mjs +1 -1
  84. package/dist-lib/cloud-test-args.cjs +1 -1
  85. package/dist-lib/cloud-test-args.mjs +1 -1
  86. package/dist-lib/cloud.cjs +17 -11
  87. package/dist-lib/cloud.mjs +17 -11
  88. package/dist-lib/config.cjs +1 -1
  89. package/dist-lib/detox/index.cjs +321 -122
  90. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  91. package/dist-lib/home-paths.cjs +1 -1
  92. package/dist-lib/host/bridge-host.cjs +427 -212
  93. package/dist-lib/host/fetch-proxy-handler.cjs +5 -28
  94. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  95. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  96. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  97. package/dist-lib/host/websocket-proxy.cjs +1 -1
  98. package/dist-lib/index.cjs +315 -58
  99. package/dist-lib/jump-to-source-babel.cjs +1 -1
  100. package/dist-lib/jump-to-source-native.cjs +1 -1
  101. package/dist-lib/menu.cjs +67 -31
  102. package/dist-lib/menu.mjs +64 -28
  103. package/dist-lib/metro-fingerprint-registry.cjs +8 -4
  104. package/dist-lib/metro-fingerprint-registry.mjs +6 -4
  105. package/dist-lib/metro-production-bundle.cjs +2 -2
  106. package/dist-lib/metro-production-bundle.mjs +2 -2
  107. package/dist-lib/metro.cjs +8 -4
  108. package/dist-lib/profiles.cjs +1 -1
  109. package/dist-lib/public-brand.cjs +7 -4
  110. package/dist-lib/react-native-host-modules.cjs +1 -1
  111. package/dist-lib/react-native-host-modules.mjs +1 -1
  112. package/dist-lib/render-mode.cjs +3 -13
  113. package/dist-lib/scripts/dev-server-scanner.cjs +5 -17
  114. package/dist-lib/sdk.cjs +129 -10
  115. package/dist-lib/sdk.mjs +127 -10
  116. package/dist-lib/skills.cjs +1635 -1121
  117. package/dist-lib/swift-compile.cjs +277 -70
  118. package/dist-lib/swift-compile.mjs +275 -70
  119. package/dist-lib/swift-package.cjs +17 -5
  120. package/dist-lib/swift-package.mjs +15 -4
  121. package/dist-lib/swift-submit.cjs +32 -8
  122. package/dist-lib/swift-submit.mjs +29 -7
  123. package/dist-lib/swift.cjs +30912 -577
  124. package/dist-lib/vite-kotlin.cjs +356 -0
  125. package/dist-lib/vite-swift.cjs +1743 -0
  126. package/dist-lib/vite.cjs +208 -1039
  127. package/kotlin/README.md +18 -0
  128. package/kotlin/Sources/androidx/annotation/Keep.kt +4 -0
  129. package/kotlin/Sources/dev/onejs/one/OneNativeSourceDispatch.kt +7 -0
  130. package/kotlin/Sources/dev/onejs/one/source/OneModule.kt +4 -0
  131. package/kotlin/Sources/org/json/JSON.kt +244 -0
  132. package/kotlin/toolchain.json +19 -0
  133. package/package.json +14 -2
  134. package/scripts/dev-server-scanner.ts +0 -18
  135. package/scripts/optional-demo-registry.ts +0 -17
  136. package/skills/contrast/SKILL.md +2 -2
  137. package/skills/peach-setup/SKILL.md +16 -0
  138. package/src/agent-daemon-client.ts +36 -22
  139. package/src/agent-identity.ts +0 -15
  140. package/src/agent-prompt.ts +12 -0
  141. package/src/attached-projects.ts +0 -72
  142. package/src/backend-origin.ts +10 -0
  143. package/src/bridge-contract-input.ts +2 -5
  144. package/src/bridge-contract.ts +2 -0
  145. package/src/capture-contract.ts +53 -11
  146. package/src/cloud.ts +4 -9
  147. package/src/dev-server-open.ts +0 -99
  148. package/src/home-paths.ts +2 -3
  149. package/src/host/agent-host.ts +82 -36
  150. package/src/host/bridge-host.ts +5 -11
  151. package/src/host/fetch-proxy-handler.ts +19 -43
  152. package/src/is-record.ts +4 -0
  153. package/src/menu.ts +82 -37
  154. package/src/metro-fingerprint-registry.ts +1 -4
  155. package/src/metro-production-bundle.ts +1 -1
  156. package/src/plist.ts +1 -4
  157. package/src/public-brand.ts +5 -3
  158. package/src/render-mode.ts +0 -12
  159. package/src/runtime-assets.ts +9 -2
  160. package/src/screenshots/compose.ts +7 -1
  161. package/src/screenshots/frame-compose.ts +21 -10
  162. package/src/screenshots/schema.ts +1 -4
  163. package/src/sim-client.ts +2 -4
  164. package/src/skills/builtin/maestro-test.ts +1 -4
  165. package/src/sleep.ts +4 -0
  166. package/src/swift-compile.ts +209 -47
  167. package/src/swift-icu.ts +140 -0
  168. package/src/swift-package.ts +26 -2
  169. package/src/swift-submit.ts +84 -4
  170. package/src/swift.ts +2 -0
  171. package/src/vite-plugin-kotlin.ts +376 -0
  172. package/src/vite-plugin-one.ts +224 -36
  173. package/src/vite-plugin-swift.ts +382 -96
  174. package/src/vite-plugin.ts +121 -134
  175. package/src/vite.d.ts +10 -0
  176. package/cli/resolve-assets.ts +0 -63
  177. package/src/rolldown-plugin-swift.ts +0 -244
  178. package/src/team-machine-prompt.ts +0 -73
@@ -14,6 +14,7 @@ import { spawnSync } from 'node:child_process'
14
14
  import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
15
15
  import { homedir } from 'node:os'
16
16
  import { dirname, extname, join, resolve } from 'node:path'
17
+ import { sleep } from '../../src/sleep'
17
18
  import {
18
19
  callInBridgeWrite,
19
20
  createBridgeFromParsed,
@@ -27,8 +28,6 @@ interface FilmOptions {
27
28
  verbose?: boolean
28
29
  }
29
30
 
30
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
31
-
32
31
  // flags consumed by film itself; everything else that buildConfigureOptions
33
32
  // understands (--background, --colorway, --env-*, --room, --focus*, ...)
34
33
  // passes through to the stage configure call.
@@ -142,26 +141,36 @@ examples:
142
141
 
143
142
  // -- phase 1: record the flow on the flat canvas ----------------------
144
143
  console.log(` film: recording flow ${flowName} on the flat canvas`)
145
- const started = await evalInPage<boolean>(
146
- bridge,
147
- `(async () => !!(await window.SootSim?.bridges?.startRecording?.('video', undefined, { skipEntitlement: true })))()`,
148
- )
149
- if (!started) {
150
- console.error(
151
- ' film: engine recording store did not start (is @peach/plugin-recording installed?)',
152
- )
153
- return 1
154
- }
155
- const t0 = Date.now()
156
- await sleep(500)
157
-
158
144
  const { runFlowPlayback } = await import('./flow')
159
145
  const playbackArgs = [flowPath]
160
146
  const simFlag = flagValue(args, '--sim')
161
147
  if (simFlag) playbackArgs.push('--sim', simFlag)
162
148
  const slowFlag = flagValue(args, '--slow')
163
149
  if (slowFlag) playbackArgs.push('--slow', slowFlag)
164
- const flowCode = await runFlowPlayback(playbackArgs)
150
+ let t0 = 0
151
+ const flowCode = await runFlowPlayback(playbackArgs, {
152
+ beforeSteps: async () => {
153
+ // the sim can arrive in 3d (left there by --keep-three, or brought back
154
+ // by the flow's open), and this phase records the flat canvas
155
+ await evalInPage(
156
+ bridge,
157
+ `(() => { const s = window.SootSim?.bridges?.settings
158
+ s?.set?.('threeRecordingEditor', false)
159
+ s?.set?.('threeMode', false) })()`,
160
+ )
161
+ const started = await evalInPage<boolean>(
162
+ bridge,
163
+ `(async () => !!(await window.SootSim?.bridges?.startRecording?.('video', undefined, { skipEntitlement: true })))()`,
164
+ )
165
+ if (!started) {
166
+ throw new Error(
167
+ 'film: engine recording store did not start (is @peach/plugin-recording installed?)',
168
+ )
169
+ }
170
+ t0 = Date.now()
171
+ await sleep(500)
172
+ },
173
+ })
165
174
  await sleep(400)
166
175
  const durationMs = Math.min(Date.now() - t0, MAX_FILM_MS)
167
176
  await evalInPage(bridge, `window.SootSim?.bridges?.stopRecording?.()`)
@@ -203,7 +203,13 @@ export async function discoverSootsimUrl(): Promise<string | null> {
203
203
  return null
204
204
  }
205
205
 
206
- export async function runFlowPlayback(args: string[]): Promise<number> {
206
+ export async function runFlowPlayback(
207
+ args: string[],
208
+ // beforeSteps runs once the flow is on its target and about to run its first
209
+ // step. opening a frontmatter `app:` navigates the sim, which wipes in-page
210
+ // state, so a caller capturing the run itself (film) starts here.
211
+ hooks: { beforeSteps?: () => Promise<void> } = {},
212
+ ): Promise<number> {
207
213
  lastPreviewUploadResult = null
208
214
  lastFlowTraceSteps = []
209
215
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
@@ -269,7 +275,7 @@ options:
269
275
  --preview-origin <url>
270
276
  upload target for --preview (default: auto)
271
277
  --preview-public-origin <url>
272
- public link origin for --preview (default: rnxsim.com
278
+ public link origin for --preview (default: peachsim.com
273
279
  for prod uploads)
274
280
  --billing-kind test_run
275
281
  meter the chained preview upload as a test run
@@ -745,6 +751,7 @@ flow extension:
745
751
  )
746
752
  }
747
753
  }
754
+ await hooks.beforeSteps?.()
748
755
  await driver.runFlow(steps, { skipPlan: dryRun })
749
756
  if (profile) {
750
757
  profileResult = await driver.stopProfile()
@@ -2,7 +2,12 @@ import { inspectTree, type InspectBridge } from './core'
2
2
  import { resolveTargetCoords, type ResolvedTargetCoords } from './resolve-target'
3
3
  import { waitForSootsimIdle } from './settling'
4
4
  import type { SimSemanticNode } from '../../../src/bridge-contract'
5
- import type { SootSimReplayTarget } from '@peach/globals'
5
+ import type {
6
+ SootSimAccessibilityState,
7
+ SootSimNodeInfo,
8
+ SootSimReplayTarget,
9
+ SootSimTapTargetInfo,
10
+ } from '@peach/globals'
6
11
 
7
12
  export type TapFailure = 'not-found' | 'missed' | 'special'
8
13
 
@@ -472,6 +477,75 @@ export async function tapByText(
472
477
  })
473
478
  }
474
479
 
480
+ export type TapQuery =
481
+ | { by: 'label' | 'role' | 'hint'; value: string }
482
+ | { by: 'state'; value: Partial<SootSimAccessibilityState> }
483
+
484
+ const TAP_QUERY_FINDERS = {
485
+ label: 'findByLabel',
486
+ role: 'findByRole',
487
+ hint: 'findByHint',
488
+ state: 'findByA11yState',
489
+ } as const
490
+
491
+ function isTapNode(value: unknown): value is SootSimNodeInfo {
492
+ return (
493
+ value !== null &&
494
+ typeof value === 'object' &&
495
+ typeof Reflect.get(value, 'nodeId') === 'number'
496
+ )
497
+ }
498
+
499
+ function isTapTargetInfo(value: unknown): value is SootSimTapTargetInfo {
500
+ return (
501
+ value !== null &&
502
+ typeof value === 'object' &&
503
+ typeof Reflect.get(value, 'cx') === 'number' &&
504
+ typeof Reflect.get(value, 'cy') === 'number' &&
505
+ isTapNode(Reflect.get(value, 'target'))
506
+ )
507
+ }
508
+
509
+ // accessibility-query taps. the test bridge finds the node and names the node
510
+ // a finger activates, then the tap runs exactly as tapById's does: settled
511
+ // first and verified at the point, so a covered target reports target-covered
512
+ // instead of pressing whatever covers it.
513
+ export async function tapByQuery(
514
+ bridge: InspectBridge,
515
+ query: TapQuery,
516
+ opts: { agent?: boolean; timing?: TapTimingOptions } = {},
517
+ ): Promise<TapOutcome> {
518
+ const call = (method: string, arg: unknown): Promise<unknown> =>
519
+ bridge.send({ type: 'call', path: `SootSim.bridges.test.${method}`, args: [arg] })
520
+ return tapResolvedTarget(bridge, {
521
+ agent: opts.agent,
522
+ timing: opts.timing,
523
+ textFallback: typeof query.value === 'string' ? query.value : undefined,
524
+ resolve: async () => {
525
+ const node = await call(TAP_QUERY_FINDERS[query.by], query.value)
526
+ if (!isTapNode(node)) return { cx: null }
527
+ const tap = await call('resolveTapTarget', node.nodeId)
528
+ if (!isTapTargetInfo(tap)) return { cx: null }
529
+ return payloadFromResolved(
530
+ {
531
+ x: tap.cx,
532
+ y: tap.cy,
533
+ nodeId: tap.target.nodeId,
534
+ testID: tap.target.testID,
535
+ text: tap.target.text ?? tap.target.accessibilityLabel,
536
+ type: tap.target.type,
537
+ },
538
+ {
539
+ nodeId: node.nodeId,
540
+ testID: node.testID,
541
+ text: node.text ?? node.accessibilityLabel,
542
+ type: node.type,
543
+ },
544
+ )
545
+ },
546
+ })
547
+ }
548
+
475
549
  export async function tapBest(
476
550
  bridge: InspectBridge,
477
551
  query: string,
@@ -328,6 +328,35 @@ export interface LayoutTextContrast {
328
328
  reason?: string
329
329
  }
330
330
 
331
+ // first family of a bound font stack, so a no-vision agent can verify a
332
+ // custom display family landed on the right nodes. system stacks stay omitted
333
+ // as a boring default. self-contained: the native eval below interpolates
334
+ // this source (mirrored by the web extractor's set in app-bridge webLayout).
335
+ export function layoutBoundFontFamily(style: unknown): string | undefined {
336
+ const raw =
337
+ typeof style === 'object' && style !== null
338
+ ? (style as { fontFamily?: unknown }).fontFamily
339
+ : undefined
340
+ if (typeof raw !== 'string') return undefined
341
+ const first = raw
342
+ .split(',')[0]
343
+ .trim()
344
+ .replace(/^['"]|['"]$/g, '')
345
+ if (!first) return undefined
346
+ const systemFamilies = new Set([
347
+ 'system',
348
+ '-apple-system',
349
+ 'system-ui',
350
+ 'blinkmacsystemfont',
351
+ 'segoe ui',
352
+ 'roboto',
353
+ 'sans-serif',
354
+ 'serif',
355
+ 'monospace',
356
+ ])
357
+ return systemFamilies.has(first.toLowerCase()) ? undefined : first
358
+ }
359
+
331
360
  export function scoreLayoutTextContrast(input: {
332
361
  normal: { width: number; height: number; data: ArrayLike<number> }
333
362
  background: { width: number; height: number; data: ArrayLike<number> }
@@ -476,6 +505,7 @@ export interface LayoutElement {
476
505
  text?: string
477
506
  // on-screen bounding box in device/viewport px (x/y are absolute).
478
507
  box: LayoutBox
508
+ hitSlop?: number | LayoutPadding
479
509
  // box-model fields are available only when the caller opts into styling.
480
510
  // measured zero remains distinct from an unavailable field.
481
511
  borderRadius?: number
@@ -491,6 +521,15 @@ export interface LayoutElement {
491
521
  bg?: string
492
522
  opacity?: number
493
523
  fontWeight?: number
524
+ // first family of the bound font stack (web: computed; native: authored
525
+ // style), so a no-vision agent can verify a custom display family landed on
526
+ // the right nodes. system stacks stay omitted as a boring default.
527
+ fontFamily?: string
528
+ // web extractor only: false when document.fonts reports the bound family
529
+ // unavailable for this element's own style and weight — the face failed to
530
+ // load and the browser fell back. omitted otherwise, including all native
531
+ // rows.
532
+ fontLoaded?: boolean
494
533
  textDecoration?: string
495
534
  overflow?: string
496
535
  borderWidth?: number
@@ -574,6 +613,7 @@ export function layoutGetNativeEval(opts: LayoutExtractionOptions = {}): string
574
613
  const n = w === 'bold' ? 700 : (typeof w === 'number' ? w : (typeof w === 'string' ? parseInt(w, 10) : NaN))
575
614
  return Number.isFinite(n) && n >= 600 ? n : undefined
576
615
  }
616
+ const familyOf = ${layoutBoundFontFamily.toString()}
577
617
  const decoOf = (style) => {
578
618
  const d = style && (style.textDecorationLine || style.textDecoration)
579
619
  return d && d !== 'none' && d !== 'normal' ? String(d).split(' ')[0] : undefined
@@ -621,6 +661,27 @@ export function layoutGetNativeEval(opts: LayoutExtractionOptions = {}): string
621
661
  const layout = n.layout || { x: 0, y: 0, width: 0, height: 0 }
622
662
  const abs = n.absolute || n.absolutePosition || { x: layout.x, y: layout.y }
623
663
  const text = typeof n.text === 'string' && n.text.trim() ? n.text.trim() : undefined
664
+ const rawHitSlop =
665
+ n.hitSlop != null
666
+ ? n.hitSlop
667
+ : (n.props && n.props.hitSlop != null)
668
+ ? n.props.hitSlop
669
+ : (n.style && n.style.hitSlop != null)
670
+ ? n.style.hitSlop
671
+ : (n.authoredStyle && n.authoredStyle.hitSlop != null)
672
+ ? n.authoredStyle.hitSlop
673
+ : undefined
674
+ const hitSlop =
675
+ typeof rawHitSlop === 'number'
676
+ ? (rawHitSlop !== 0 ? rawHitSlop : undefined)
677
+ : rawHitSlop && typeof rawHitSlop === 'object'
678
+ ? {
679
+ t: round(rawHitSlop.top ?? rawHitSlop.t),
680
+ r: round(rawHitSlop.right ?? rawHitSlop.r),
681
+ b: round(rawHitSlop.bottom ?? rawHitSlop.b),
682
+ l: round(rawHitSlop.left ?? rawHitSlop.l),
683
+ }
684
+ : undefined
624
685
  const base = {
625
686
  ...(typeof n.nodeId === 'number'
626
687
  ? {
@@ -635,6 +696,7 @@ export function layoutGetNativeEval(opts: LayoutExtractionOptions = {}): string
635
696
  type: typeof n.type === 'string' ? n.type : 'node',
636
697
  text,
637
698
  box: { x: round(abs.x), y: round(abs.y), w: round(layout.width), h: round(layout.height) },
699
+ ...(hitSlop !== undefined ? { hitSlop } : {}),
638
700
  }
639
701
  if (!includeStyle) return base
640
702
  // computedStyle has the resolved values; style is the curated subset —
@@ -651,6 +713,7 @@ export function layoutGetNativeEval(opts: LayoutExtractionOptions = {}): string
651
713
  bg: colorStr(style.backgroundColor),
652
714
  opacity: opacityOf(style),
653
715
  fontWeight: weightOf(style),
716
+ fontFamily: text ? familyOf(style) : undefined,
654
717
  textDecoration: decoOf(style),
655
718
  overflow: overflowOf(style),
656
719
  borderWidth,
@@ -773,6 +836,17 @@ export function formatLayoutElements(
773
836
  const uniform = p.t === p.r && p.r === p.b && p.b === p.l
774
837
  parts.push(uniform ? `pad:${p.t}` : `pad:${p.t},${p.r},${p.b},${p.l}`)
775
838
  }
839
+ if (el.hitSlop !== undefined) {
840
+ if (typeof el.hitSlop === 'number') {
841
+ parts.push(`hitSlop:${el.hitSlop}`)
842
+ } else {
843
+ const hs = el.hitSlop
844
+ const uniform = hs.t === hs.r && hs.r === hs.b && hs.b === hs.l
845
+ parts.push(
846
+ uniform ? `hitSlop:${hs.t}` : `hitSlop:${hs.t},${hs.r},${hs.b},${hs.l}`,
847
+ )
848
+ }
849
+ }
776
850
  if (el.fontSize !== undefined && el.fontSize > 0) {
777
851
  parts.push(`font:${el.fontSize}`)
778
852
  }
@@ -781,6 +855,8 @@ export function formatLayoutElements(
781
855
  // visual quality (contrast, washed-out, garbled/struck, clipping, flat).
782
856
  if (opts?.styling) {
783
857
  if (el.fontWeight) parts.push(`weight:${el.fontWeight}`)
858
+ if (el.fontFamily) parts.push(`family:"${el.fontFamily}"`)
859
+ if (el.fontLoaded === false) parts.push(`fontLoaded:false`)
784
860
  if (el.color) parts.push(`color:${el.color}`)
785
861
  if (el.bg) parts.push(`bg:${el.bg}`)
786
862
  if (el.contrast) {
@@ -824,7 +900,8 @@ export interface AccessibilityTreeNode {
824
900
  }
825
901
 
826
902
  // `get a11y` — flat accessibility summary projected from the visible semantic
827
- // tree, which already excludes hidden and covered content.
903
+ // tree, which already excludes hidden and covered content, minus the painted
904
+ // content VoiceOver cannot reach.
828
905
  export async function inspectAccessibilityTree(
829
906
  bridge: InspectBridge,
830
907
  ): Promise<AccessibilityTreeNode[]> {
@@ -834,6 +911,7 @@ export async function inspectAccessibilityTree(
834
911
  })
835
912
  if (!isSemanticQueryResult(result)) return []
836
913
  return result.nodes.flatMap((node) => {
914
+ if (node.accessibilityHidden) return []
837
915
  if (!node.role && !node.label && !node.text && !node.pressable) return []
838
916
  return [
839
917
  {
@@ -985,7 +1063,8 @@ export function scoreInteractive(n: InteractiveNode): number {
985
1063
  export function tapCommandForNode(n: InteractiveNode): string {
986
1064
  if (n.testID) return `peach do tap-id ${shellEscape(n.testID)}`
987
1065
  const text = typeof n.text === 'string' ? n.text.trim() : ''
988
- if (text.length > 0 && text.length <= 80) return `peach do tap-text ${shellEscape(text)}`
1066
+ if (text.length > 0 && text.length <= 80)
1067
+ return `peach do tap-text ${shellEscape(text)}`
989
1068
  const x =
990
1069
  Math.round(((n.absolutePosition?.x ?? 0) + (n.layout?.width ?? 0) / 2) * 10) / 10
991
1070
  const y =
@@ -1,13 +1,9 @@
1
1
  // `peach describe` — format the pruned visible semantic tree.
2
2
 
3
+ import { sleep } from '../../../src/sleep'
3
4
  import { maybeHint } from '../../hints'
4
5
  import { inspectDescribe, type DescribeDumpOptions } from './core'
5
- import {
6
- maybeWaitForStartedScreenTransitions,
7
- printJson,
8
- sleep,
9
- wantsJson,
10
- } from './shared'
6
+ import { maybeWaitForStartedScreenTransitions, printJson, wantsJson } from './shared'
11
7
  import type { WsBridge } from '../../ws-bridge'
12
8
 
13
9
  export interface DescribeOptions {
@@ -4,7 +4,7 @@
4
4
  // flicker the way a human can.
5
5
  const AGENT_ENV_VARS = [
6
6
  'PEACH_AGENT',
7
- 'TM_SESSION',
7
+ 'PEACH_CLI_IDENTITY',
8
8
  'CLAUDECODE',
9
9
  'CLAUDE_CODE_ENTRYPOINT',
10
10
  'CLAUDE_CODE_SESSION_ID',
@@ -7,6 +7,10 @@ export type WaitForSootsimIdleOptions = {
7
7
  pollMs?: number
8
8
  stablePolls?: number
9
9
  strict?: boolean
10
+ // also require no newly painted frame across the window, the way maestro
11
+ // compares screenshots: a spinner or a rotation repaints without moving
12
+ // layout or registering as a driven animation.
13
+ paintStill?: boolean
10
14
  }
11
15
 
12
16
  export type WaitForSootsimIdleResult = {
@@ -25,6 +29,7 @@ export async function waitForSootsimIdle({
25
29
  pollMs = 50,
26
30
  stablePolls = 3,
27
31
  strict = false,
32
+ paintStill = false,
28
33
  }: WaitForSootsimIdleOptions): Promise<WaitForSootsimIdleResult> {
29
34
  const result = await bridge.send(
30
35
  {
@@ -36,6 +41,7 @@ export async function waitForSootsimIdle({
36
41
  const pollMs = ${Math.max(1, Math.round(pollMs))}
37
42
  const requiredStablePolls = ${Math.max(1, Math.round(stablePolls))}
38
43
  const strict = ${strict ? 'true' : 'false'}
44
+ const paintStill = ${paintStill ? 'true' : 'false'}
39
45
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
40
46
 
41
47
  // route-aware settle: a tap that pushes/pops a screen is async — the
@@ -67,10 +73,12 @@ export async function waitForSootsimIdle({
67
73
  let requestTotal = 0
68
74
  let requestInFlight = 0
69
75
  let renderStatsAvailable = false
76
+ let frameCount = -1
70
77
  try {
71
78
  const stats = await window.__sootsimRenderHost?.queryStats?.()
72
79
  if (stats) {
73
80
  renderStatsAvailable = true
81
+ frameCount = typeof stats.frameCount === 'number' ? stats.frameCount : -1
74
82
  animating =
75
83
  stats.hasActiveAnims === true ||
76
84
  stats.hasActiveNativeAnimations === true ||
@@ -103,7 +111,7 @@ export async function waitForSootsimIdle({
103
111
  }
104
112
  walk(root)
105
113
  }
106
- return { layout: nodes.join(';'), animating, layoutDirty, pendingFetches, renderStatsAvailable, requestTotal, requestInFlight }
114
+ return { layout: nodes.join(';'), frameCount, animating, layoutDirty, pendingFetches, renderStatsAvailable, requestTotal, requestInFlight }
107
115
  }
108
116
 
109
117
  // how long generic background network is allowed to keep us waiting AFTER
@@ -118,6 +126,8 @@ export async function waitForSootsimIdle({
118
126
  // only within this grace once the screen is visually still.
119
127
  const networkQuietGraceMs = 1200
120
128
  let lastLayout = ''
129
+ let lastFrameCount = -2
130
+ let paintedPolls = 0
121
131
  let lastRequestTotal = -1
122
132
  let stable = 0
123
133
  let visuallyStillSince = 0
@@ -134,13 +144,18 @@ export async function waitForSootsimIdle({
134
144
  if (snapshot.layoutDirty) dirtyPolls++
135
145
  if (snapshot.animating) animatingPolls++
136
146
  if (polls > 1 && snapshot.layout !== lastLayout) layoutChangedPolls++
147
+ const painted = paintStill && snapshot.frameCount !== lastFrameCount
148
+ if (polls > 1 && painted) paintedPolls++
137
149
  const strictOk =
138
150
  !strict ||
139
151
  (snapshot.renderStatsAvailable && !snapshot.animating && !snapshot.layoutDirty)
140
152
  // visually settled: no animations, no image content loading, layout
141
153
  // unchanged since the last poll.
142
154
  const visuallyStill =
143
- strictOk && snapshot.pendingFetches === 0 && snapshot.layout === lastLayout
155
+ strictOk &&
156
+ !painted &&
157
+ snapshot.pendingFetches === 0 &&
158
+ snapshot.layout === lastLayout
144
159
  // generic network quiet: nothing in flight and no new requests issued.
145
160
  const networkQuiet =
146
161
  snapshot.requestInFlight === 0 && snapshot.requestTotal === lastRequestTotal
@@ -161,6 +176,7 @@ export async function waitForSootsimIdle({
161
176
  visuallyStillSince = 0
162
177
  }
163
178
  lastLayout = snapshot.layout
179
+ lastFrameCount = snapshot.frameCount
164
180
  lastRequestTotal = snapshot.requestTotal
165
181
  await sleep(pollMs)
166
182
  }
@@ -171,6 +187,7 @@ export async function waitForSootsimIdle({
171
187
  if (last.layoutDirty) blockers.push('layout still dirty')
172
188
  if (last.pendingFetches > 0) blockers.push(last.pendingFetches + ' image fetches pending')
173
189
  if (last.layout !== lastLayout) blockers.push('layout still changing')
190
+ if (paintStill && last.frameCount !== lastFrameCount) blockers.push('still painting')
174
191
  if (last.requestInFlight > 0) blockers.push(last.requestInFlight + ' requests in flight')
175
192
  return {
176
193
  settled: false,
@@ -178,7 +195,7 @@ export async function waitForSootsimIdle({
178
195
  blockedBy:
179
196
  (blockers.length ? blockers.join(', ') : 'unknown') +
180
197
  ' over ' + polls + ' polls (layout changed ' + layoutChangedPolls +
181
- 'x, layoutDirty ' + dirtyPolls + 'x, animating ' + animatingPolls + 'x)',
198
+ 'x, layoutDirty ' + dirtyPolls + 'x, animating ' + animatingPolls + 'x, painted ' + paintedPolls + 'x)',
182
199
  }
183
200
  })()`,
184
201
  },
@@ -4,6 +4,7 @@
4
4
  // own file without reaching back into a 4000-line switch. everything here is
5
5
  // call-agnostic: just bridge-facing helpers that any subcommand can use.
6
6
 
7
+ import { sleep } from '../../../src/sleep'
7
8
  import {
8
9
  callInBridge,
9
10
  callInBridgeWrite,
@@ -25,10 +26,6 @@ export const SCREEN_TRANSITION_PREFLIGHT = {
25
26
  startWindowMs: 64,
26
27
  } as const
27
28
 
28
- export function sleep(ms: number) {
29
- return new Promise((resolve) => setTimeout(resolve, ms))
30
- }
31
-
32
29
  // true when the caller passed --json. read verbs use this to route their
33
30
  // payload to stdout-as-json and send everything else (headers, hints,
34
31
  // warnings) to stderr so `jq` on stdout never sees framing noise.
@@ -1,5 +1,5 @@
1
+ import { sleep } from '../../../src/sleep'
1
2
  import { rnxExit } from '../../run-rnx'
2
- import { sleep } from './shared'
3
3
 
4
4
  export async function runSleepSubcommand(opts: {
5
5
  positional: string[]
@@ -5,6 +5,7 @@ import { getCliInspectVerbMeta } from '@peach/skills/cli'
5
5
  import { renderCliCommandHelp, renderCliGroupHelp } from '@peach/skills/cli/renderers'
6
6
  import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
7
7
  import { DEFAULT_SOOTSIM_SHELL_URL } from '../../src/cli-constants'
8
+ import { sleep } from '../../src/sleep'
8
9
  import { resetGuestAppStateEval } from '../app-state-reset'
9
10
  import { printBridgeStateSummary } from '../bridge-diagnostics'
10
11
  import {
@@ -87,7 +88,6 @@ import {
87
88
  printJson,
88
89
  printWarn,
89
90
  type ReloadReadyResult,
90
- sleep,
91
91
  SCREEN_TRANSITION_PREFLIGHT,
92
92
  waitForBridgeConnected,
93
93
  wantsJson,
@@ -1148,9 +1148,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1148
1148
  if (!subcommand || args.includes('--help') || args.includes('-h')) {
1149
1149
  // all help output comes from the registry in packages/peach-skills
1150
1150
  // so `peach do --help`, `peach get --help`, the website docs, and
1151
- // the Contrast skill markdown stay in lock-step. a drift check in
1152
- // test/sootsimCliRegistry.test.ts fails CI if a verb lands without a
1153
- // matching registry entry.
1151
+ // the Contrast skill markdown stay in lock-step.
1154
1152
  const docContext = {
1155
1153
  bridgePort: DEFAULT_SOOTSIM_BRIDGE_PORT,
1156
1154
  defaultShellUrl: DEFAULT_SOOTSIM_SHELL_URL,
@@ -2859,11 +2857,18 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2859
2857
  type: resolved.type ?? null,
2860
2858
  }
2861
2859
  }
2862
- const durationArg = target ? positional[1] : positional[3]
2860
+ const durationArg = target
2861
+ ? Number.isFinite(Number(positional[1]))
2862
+ ? positional[1]
2863
+ : positional[2]
2864
+ : positional[3]
2863
2865
  const durationMs = durationArg ? Number(durationArg) : 600
2864
2866
  if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(durationMs)) {
2865
2867
  console.error(
2866
- inspectUsage('long-press', '<testid> | <x> <y> [durationMs] | --testid <id>'),
2868
+ inspectUsage(
2869
+ 'long-press',
2870
+ '<testid> [durationMs] | <x> <y> [durationMs] | --testid <id>',
2871
+ ),
2867
2872
  )
2868
2873
  rnxExit(1)
2869
2874
  }
@@ -2887,50 +2892,12 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2887
2892
  }
2888
2893
 
2889
2894
  case 'touch': {
2890
- const phase = positional[1]
2891
- const x = Number(positional[2])
2892
- const y = Number(positional[3])
2893
- const pointerId = positional[4] ? Number(positional[4]) : 999
2894
- const method =
2895
- phase === 'down'
2896
- ? 'touchDown'
2897
- : phase === 'move'
2898
- ? 'touchMove'
2899
- : phase === 'up'
2900
- ? 'touchUp'
2901
- : phase === 'cancel'
2902
- ? 'touchCancel'
2903
- : null
2904
- if (!method) {
2905
- console.error(
2906
- inspectUsage('touch', '<down|move|up|cancel> <x> <y> [pointerId]'),
2907
- )
2908
- rnxExit(1)
2909
- }
2910
- if (phase !== 'cancel' && (!Number.isFinite(x) || !Number.isFinite(y))) {
2911
- console.error(
2912
- inspectUsage('touch', '<down|move|up|cancel> <x> <y> [pointerId]'),
2913
- )
2914
- rnxExit(1)
2915
- }
2916
- // the engine's perform executor emits the matching agent-cursor
2917
- // action (tap for down, move for move) natively.
2918
- const roundedPointerId = Math.max(1, Math.round(pointerId))
2919
- const result = await performSingleStep(
2920
- bridge,
2921
- method === 'touchCancel'
2922
- ? { type: method, pointerId: roundedPointerId }
2923
- : { type: method, x, y, pointerId: roundedPointerId },
2895
+ // perform batches are self-contained (a pointer never outlives its
2896
+ // batch), so a lone phase could never hold a press between commands.
2897
+ console.error(
2898
+ ' error: a touch lives within one batch; chain its phases, e.g. peach do touch down 200 720 --then touch move 200 400 --then touch up 200 400',
2924
2899
  )
2925
- if (result?.ok && phase !== 'cancel') {
2926
- await recordInspectAction(
2927
- 'inspect touch',
2928
- { tapAtCoords: { x, y } },
2929
- `touch ${phase} @${x},${y}`,
2930
- )
2931
- }
2932
- console.log(JSON.stringify(result, null, 2))
2933
- break
2900
+ rnxExit(1)
2934
2901
  }
2935
2902
 
2936
2903
  case 'gesture': {
@@ -4,7 +4,7 @@
4
4
  // for users who want the Electron-based GUI companion on top of that.
5
5
  //
6
6
  // fetches the right platform artifact from https://contrast.dev/api/electron-release/
7
- // and installs it into the OS-native location that findDesktopCompanion() looks for:
7
+ // and installs it into the OS-native location that findInstalledDesktopCompanion() looks for:
8
8
  // macos: ~/Applications/peach.app (or /Applications if writable)
9
9
  // linux: ~/Applications/peach.AppImage
10
10
  // win: spawns the NSIS installer
@@ -25,7 +25,7 @@ import {
25
25
  } from 'fs'
26
26
  import { tmpdir } from 'os'
27
27
  import { dirname, join, resolve } from 'path'
28
- import { findDesktopCompanion } from '../desktop-companion'
28
+ import { findInstalledDesktopCompanion } from '../desktop-companion'
29
29
  import { confirm } from '../prompt'
30
30
  import { rnxExit } from '../run-rnx'
31
31
  import { accent, bold, failure, muted, success, withSpinner } from '../ui'
@@ -155,7 +155,7 @@ async function installMacDmg(dmgPath: string): Promise<string> {
155
155
  }
156
156
 
157
157
  // prefer /Applications if writable, otherwise ~/Applications (same paths
158
- // findDesktopCompanion checks, so no extra config needed post-install).
158
+ // findInstalledDesktopCompanion checks, so no extra config needed post-install).
159
159
  const systemApps = '/Applications'
160
160
  const userApps = resolve(process.env.HOME || '', 'Applications')
161
161
  let destRoot = systemApps
@@ -249,7 +249,7 @@ async function installWindowsExe(exePath: string): Promise<string> {
249
249
  else rejectPromise(new Error(`installer exited with code ${code}`))
250
250
  })
251
251
  })
252
- const installed = findDesktopCompanion()
252
+ const installed = findInstalledDesktopCompanion()
253
253
  if (!installed) {
254
254
  throw new Error('the installer finished but the peach application was not registered')
255
255
  }
@@ -286,7 +286,7 @@ examples:
286
286
  !process.stdin.isTTY
287
287
  const force = args.includes('--force')
288
288
 
289
- const existing = findDesktopCompanion()
289
+ const existing = findInstalledDesktopCompanion()
290
290
  if (existing && !force) {
291
291
  console.log(` ${success('✓')} Desktop app is already installed at ${existing.path}`)
292
292
  console.log(` ${muted('Pass --force to reinstall.')}`)
@@ -347,7 +347,9 @@ examples:
347
347
  console.error(
348
348
  ' network blocked the request. Check your connection and try again later.',
349
349
  )
350
- console.error(' The CLI and daemon work without the desktop app. Run `peach --help`.')
350
+ console.error(
351
+ ' The CLI and daemon work without the desktop app. Run `peach --help`.',
352
+ )
351
353
  rnxExit(1)
352
354
  }
353
355