peachsim 0.1.628 → 0.1.630

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 (111) hide show
  1. package/README.md +8 -4
  2. package/cli/bridge-flow-runner.ts +10 -3
  3. package/cli/cli-update.ts +2 -7
  4. package/cli/cloud-test-tunnel.ts +16 -3
  5. package/cli/commands/config.ts +1 -1
  6. package/cli/commands/control.ts +28 -3
  7. package/cli/commands/daemon-mac-app.ts +4 -2
  8. package/cli/commands/daemon.ts +4 -4
  9. package/cli/commands/device.ts +2 -3
  10. package/cli/commands/inspect/actions.ts +125 -90
  11. package/cli/commands/inspect/core.ts +5 -0
  12. package/cli/commands/inspect.ts +27 -13
  13. package/cli/commands/platform.ts +1 -1
  14. package/cli/commands/screenshot-layers.ts +1 -1
  15. package/cli/commands/screenshot.ts +1 -1
  16. package/cli/commands/skills.ts +0 -31
  17. package/cli/drivers/playwright-provisioning.ts +3 -2
  18. package/cli/drivers/playwright-sim-host.ts +25 -9
  19. package/cli/main.ts +8 -2
  20. package/cli/outbound-endpoints.ts +0 -4
  21. package/detox/index.ts +74 -43
  22. package/dist-lib/agent-daemon-client.cjs +1 -1
  23. package/dist-lib/agent-events.cjs +1 -1
  24. package/dist-lib/agent-identity.cjs +1 -1
  25. package/dist-lib/agent-sessions.cjs +1 -1
  26. package/dist-lib/attached-projects.cjs +1 -1
  27. package/dist-lib/auth/shared-session.cjs +1 -1
  28. package/dist-lib/backend-origin.cjs +1 -1
  29. package/dist-lib/beta.cjs +1 -1
  30. package/dist-lib/beta.mjs +1 -1
  31. package/dist-lib/bridge-constants.cjs +1 -1
  32. package/dist-lib/bridge-contract-input.cjs +1 -1
  33. package/dist-lib/bridge-contract-input.mjs +1 -1
  34. package/dist-lib/bridge-contract.cjs +1 -1
  35. package/dist-lib/bridge-contract.mjs +1 -1
  36. package/dist-lib/capture-contract.cjs +1 -1
  37. package/dist-lib/capture-contract.mjs +1 -1
  38. package/dist-lib/cli-constants.cjs +1 -1
  39. package/dist-lib/cloud-contract.cjs +1 -1
  40. package/dist-lib/cloud-contract.mjs +1 -1
  41. package/dist-lib/cloud-test-args.cjs +1 -1
  42. package/dist-lib/cloud-test-args.mjs +1 -1
  43. package/dist-lib/cloud.cjs +1 -1
  44. package/dist-lib/cloud.mjs +1 -1
  45. package/dist-lib/config.cjs +1 -1
  46. package/dist-lib/detox/index.cjs +129 -27
  47. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  48. package/dist-lib/home-paths.cjs +1 -1
  49. package/dist-lib/host/bridge-host.cjs +290 -315
  50. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  51. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  52. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  53. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  54. package/dist-lib/host/websocket-proxy.cjs +1 -1
  55. package/dist-lib/index.cjs +253 -121
  56. package/dist-lib/jump-to-source-babel.cjs +1 -1
  57. package/dist-lib/jump-to-source-native.cjs +1 -1
  58. package/dist-lib/{vite-kotlin.cjs → kotlin-host.cjs} +130 -19
  59. package/dist-lib/menu.cjs +1 -1
  60. package/dist-lib/menu.mjs +1 -1
  61. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  62. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  63. package/dist-lib/metro-production-bundle.cjs +1 -1
  64. package/dist-lib/metro-production-bundle.mjs +1 -1
  65. package/dist-lib/metro.cjs +132 -9
  66. package/dist-lib/native-app-identity.cjs +278 -0
  67. package/dist-lib/native-app-identity.mjs +245 -0
  68. package/dist-lib/profiles.cjs +1 -1
  69. package/dist-lib/public-brand.cjs +1 -1
  70. package/dist-lib/react-native-host-modules.cjs +1 -1
  71. package/dist-lib/react-native-host-modules.mjs +1 -1
  72. package/dist-lib/render-mode.cjs +1 -1
  73. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  74. package/dist-lib/sdk.cjs +110 -87
  75. package/dist-lib/sdk.mjs +110 -87
  76. package/dist-lib/skills.cjs +841 -643
  77. package/dist-lib/swift-compile.cjs +1 -1
  78. package/dist-lib/swift-compile.mjs +1 -1
  79. package/dist-lib/swift-package.cjs +1 -1
  80. package/dist-lib/swift-package.mjs +1 -1
  81. package/dist-lib/swift-submit.cjs +1 -1
  82. package/dist-lib/swift-submit.mjs +1 -1
  83. package/dist-lib/swift.cjs +282 -177
  84. package/dist-lib/vite-swift.cjs +1 -1
  85. package/dist-lib/vite.cjs +11 -28
  86. package/kotlin/README.md +32 -4
  87. package/kotlin/fixtures/counter/Counter.kt +26 -0
  88. package/kotlin/fixtures/counter/CounterScreen.kt +40 -0
  89. package/kotlin/fixtures/headless-counter/Headless.kt +228 -0
  90. package/kotlin/fixtures/runtime-state/RuntimeState.kt +200 -0
  91. package/kotlin/fork/LICENSE +178 -0
  92. package/kotlin/fork/NOTICE +25 -0
  93. package/kotlin/fork/README.md +59 -0
  94. package/kotlin/fork/build.sh +128 -0
  95. package/kotlin/fork/compose-worker-globals.patch +96 -0
  96. package/kotlin/fork/fork.json +33 -0
  97. package/kotlin/fork/publish.ts +77 -0
  98. package/kotlin/fork/skiko-worker-clock.patch +33 -0
  99. package/kotlin/toolchain.json +288 -0
  100. package/package.json +12 -5
  101. package/src/automatic-update-policy.ts +17 -0
  102. package/src/host/bridge-host.ts +3 -1
  103. package/src/{vite-plugin-kotlin.ts → kotlin-host.ts} +155 -12
  104. package/src/metro-plugin.ts +18 -0
  105. package/src/native-app-identity.ts +376 -0
  106. package/src/native-firebase-config.ts +90 -0
  107. package/src/screenshots/frame-compose.ts +5 -2
  108. package/src/screenshots/registry.ts +1 -1
  109. package/src/screenshots/schema.ts +2 -2
  110. package/src/vite-plugin-one.ts +11 -28
  111. package/src/vite-plugin.ts +6 -1
package/README.md CHANGED
@@ -15,8 +15,10 @@ The executable updates itself with `peach upgrade`. Successful interactive human
15
15
  commands refresh a cached stable CLI version in a detached process at most once
16
16
  every 20 hours. A newer version produces one short notice on stderr, once per
17
17
  version and no more than weekly. CI, agents, JSON output, pipes, and
18
- noninteractive commands stay silent. Set `PEACH_UPDATE_CHECK=off` to disable the
19
- check and notice; `peach version` checks explicitly, while `peach --version` remains
18
+ noninteractive commands stay silent. Set `PEACH_UPDATE_CHECK=off` to disable
19
+ automatic CLI checks, runtime updates, and their notices. Start the bridge with
20
+ this environment, or reinstall the background service with it to persist the choice.
21
+ `peach version` checks explicitly, while `peach --version` remains
20
22
  offline.
21
23
 
22
24
  Neither artifact contains rendering code. The canvas engine (CanvasKit
@@ -233,7 +235,7 @@ Pass a target to `peach open <port-or-url>` when a script or agent needs an expl
233
235
  selection.
234
236
 
235
237
  The first `peach open` launches an isolated Chrome for Testing sim. The CLI
236
- resolves Playwright from the app or CLI install, asks that exact package for
238
+ resolves Playwright from the current directory or beside its executable, asks that exact package for
237
239
  its required browser executable, and runs that package's installer once when
238
240
  the executable is absent. It never substitutes another revision found in the
239
241
  shared Playwright cache. Later opens reuse that sim and reload it at the
@@ -557,7 +559,9 @@ frame for quick art-direction passes.
557
559
  ## Skills registry
558
560
 
559
561
  `peach skill install` installs Peach agent skills in the standard
560
- `<skill-name>/SKILL.md` layout used by Codex and Claude Code. The bundled
562
+ `<skill-name>/SKILL.md` layout used by Codex and Claude Code. Building the CLI
563
+ only writes build artifacts; run `peach skill install` explicitly to install
564
+ or refresh agent skills. The bundled
561
565
  skills cover setup, debugging/perf/accessibility, testing, visual review, and
562
566
  the generated `contrast` command index. The published CLI registry and generated
563
567
  website docs both come from `packages/peach-skills/`:
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs'
2
2
  import * as path from 'path'
3
- import { devices, type DeviceModel } from 'peach-engine/settings'
3
+ import { devices, type DeviceModel } from 'peach-engine/settings/devices'
4
4
  import yaml from 'yaml'
5
5
  import { REQUIRED_IDENTICAL_FLOW_FRAMES, framesIdentical } from '../detox/proof-frame.cjs'
6
6
  import { flowTimeoutScale } from '../src/flow-timeout-scale'
@@ -1314,6 +1314,9 @@ export class SootSimBridgeFlowRunner {
1314
1314
 
1315
1315
  async tapOn(target: string | FlowPointTarget) {
1316
1316
  const opts = typeof target === 'string' ? { text: target } : target
1317
+ // maestro settles before resolving a tap's final position as well as after
1318
+ // delivering it. a preceding assertion does not settle a pending update.
1319
+ await this.waitForAppToSettle()
1317
1320
  const repeat = Math.max(1, Math.trunc(opts.repeat ?? 1))
1318
1321
  const repeatDelay = opts.delay !== undefined && opts.delay >= 0 ? opts.delay : 100
1319
1322
  // maestro Maestro.hierarchyBasedTap with a TapRepeat: every tap is padded
@@ -2379,14 +2382,14 @@ export class SootSimBridgeFlowRunner {
2379
2382
  }
2380
2383
  }
2381
2384
 
2382
- // maestro waits after every input for the screen to stop changing, up to
2385
+ // maestro waits around taps for the screen to stop changing, up to
2383
2386
  // its ios driver's 3 s settle budget, comparing screenshots, so an action
2384
2387
  // still in flight (a submit spinner, a sheet closing) finishes before the
2385
2388
  // next command resolves its target. an app can take a few hundred ms to
2386
2389
  // react to a press, so stillness must hold about half a second, and it
2387
2390
  // counts painted frames because a spinner repaints without moving layout.
2388
2391
  // a screen that never stops proceeds when the budget runs out, as there.
2389
- private async settleAfterInput() {
2392
+ private async waitForAppToSettle() {
2390
2393
  await waitForSootsimIdle({
2391
2394
  bridge: this.bridge,
2392
2395
  simId: this.opts.simId,
@@ -2396,6 +2399,10 @@ export class SootSimBridgeFlowRunner {
2396
2399
  strict: true,
2397
2400
  paintStill: true,
2398
2401
  })
2402
+ }
2403
+
2404
+ private async settleAfterInput() {
2405
+ await this.waitForAppToSettle()
2399
2406
  this.lastInteractionAt = Date.now()
2400
2407
  }
2401
2408
 
package/cli/cli-update.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { compareSemver } from '@contrast/runtime-delivery'
4
+ import { automaticPeachUpdatesEnabled } from '../src/automatic-update-policy'
4
5
  import { getCliVersion } from '../src/cli-version'
5
6
  import { cliUpdateFilePath } from '../src/home-paths'
6
7
  import { isAgentEnv } from './commands/inspect/env'
@@ -127,12 +128,6 @@ function envFlagIsOn(value: string | undefined): boolean {
127
128
  return Boolean(value) && !envFlagIsOff(value)
128
129
  }
129
130
 
130
- export function automaticCliUpdateChecksEnabled(
131
- env: Readonly<Record<string, string | undefined>> = process.env,
132
- ): boolean {
133
- return !envFlagIsOff(env.PEACH_UPDATE_CHECK)
134
- }
135
-
136
131
  export function shouldUseAutomaticCliUpdateCheck(
137
132
  invocation: CliUpdateInvocation,
138
133
  ): boolean {
@@ -143,7 +138,7 @@ export function shouldUseAutomaticCliUpdateCheck(
143
138
  invocation.version ||
144
139
  !invocation.stdoutIsTTY ||
145
140
  !invocation.stderrIsTTY ||
146
- !automaticCliUpdateChecksEnabled(env) ||
141
+ !automaticPeachUpdatesEnabled(env) ||
147
142
  envFlagIsOn(env.CI) ||
148
143
  envFlagIsOn(env.GITHUB_ACTIONS) ||
149
144
  envFlagIsOn(env.PEACH_NO_PROMPT) ||
@@ -26,6 +26,7 @@ import {
26
26
  import { createServer, request as httpRequest } from 'node:http'
27
27
  import { tmpdir } from 'node:os'
28
28
  import { join } from 'node:path'
29
+ import { pipeline } from 'node:stream'
29
30
  import { sleep } from '../src/sleep'
30
31
 
31
32
  // must match packages/contrast-runner/src/hostedPorts.ts.
@@ -160,12 +161,24 @@ export function startCloudTestTokenProxy(
160
161
  },
161
162
  (upRes) => {
162
163
  res.writeHead(upRes.statusCode || 502, upRes.headers)
163
- upRes.pipe(res)
164
+ // a broken upstream body must break the downstream response too. pipe()
165
+ // leaves it open after an aborted transfer, stranding the tester.
166
+ pipeline(upRes, res, (error) => {
167
+ if (error) {
168
+ console.error(
169
+ `upstream body error ${req.method} ${req.url}: ${error.message}`,
170
+ )
171
+ }
172
+ })
164
173
  },
165
174
  )
166
175
  up.on('error', (err) => {
167
- res.writeHead(502, { 'content-type': 'text/plain' })
168
- res.end(`upstream error: ${err.message}`)
176
+ // a transport failure is a network error, including before headers
177
+ // arrive; ending a partial response would report a successful transfer.
178
+ res.destroy(err)
179
+ })
180
+ res.once('close', () => {
181
+ if (!res.writableFinished) up.destroy()
169
182
  })
170
183
  req.pipe(up)
171
184
  })
@@ -11,7 +11,7 @@ usage:
11
11
  environment:
12
12
  PEACH_HINTS=off suppress CLI hints
13
13
  PEACH_HINTS=always show every CLI hint every time
14
- PEACH_UPDATE_CHECK=off disable background CLI update checks and notices
14
+ PEACH_UPDATE_CHECK=off disable automatic CLI checks, runtime updates, and notices
15
15
  `)
16
16
  }
17
17
 
@@ -8,7 +8,7 @@ import {
8
8
  listSelectableDeviceModels,
9
9
  resolveDeviceFitBox,
10
10
  type DeviceModel,
11
- } from 'peach-engine/settings'
11
+ } from 'peach-engine/settings/devices'
12
12
  import { WebSocket } from 'ws'
13
13
  import { probePort } from '../../scripts/dev-server-scanner'
14
14
  import { isAppDeepLinkTarget, openAppUrl } from '../../src/app-url'
@@ -36,6 +36,7 @@ import {
36
36
  runtimeDir,
37
37
  } from '../../src/home-paths'
38
38
  import { applyHotMode } from '../../src/native-dev-bundle-url'
39
+ import { readNativeFirebaseConfig } from '../../src/native-firebase-config'
39
40
  import { ensureProfile } from '../../src/profiles'
40
41
  import { rnxPublicBrand } from '../../src/public-brand'
41
42
  import {
@@ -774,6 +775,26 @@ async function stampAppSplash(
774
775
  }
775
776
  }
776
777
 
778
+ function stampNativeFirebaseConfig(
779
+ urlStr: string,
780
+ project?: ResolvedAppProject | null,
781
+ ): string {
782
+ const url = new URL(urlStr)
783
+ if (!url.searchParams.get('bundle') || !project) return urlStr
784
+ try {
785
+ url.searchParams.set(
786
+ 'nativeFirebase',
787
+ JSON.stringify(readNativeFirebaseConfig(project.appDir)),
788
+ )
789
+ } catch (error) {
790
+ url.searchParams.set(
791
+ 'nativeFirebase',
792
+ JSON.stringify({ error: error instanceof Error ? error.message : String(error) }),
793
+ )
794
+ }
795
+ return url.toString()
796
+ }
797
+
777
798
  // stamp the engine page url with `?appCatalog=` so an app's asset-catalog
778
799
  // colors and its declared accent reach the tenant, which applies them to the
779
800
  // bundle this url opens. the catalog lives in the native binary, never in
@@ -867,8 +888,11 @@ export async function buildShellUrl(
867
888
  // fallback typeface, which then reads as a conformance diff.
868
889
  const project =
869
890
  opts.project ?? (await resolveAppProjectForBundleUrl(bundleUrlOf(resolved)))
870
- const stamped = stampAppCatalog(
871
- await stampAppSplash(await stampAppFonts(resolved, project), project),
891
+ const stamped = stampNativeFirebaseConfig(
892
+ stampAppCatalog(
893
+ await stampAppSplash(await stampAppFonts(resolved, project), project),
894
+ project,
895
+ ),
872
896
  project,
873
897
  )
874
898
  if (
@@ -912,6 +936,7 @@ function deriveCurrentSimBaseUrl(
912
936
  // bundle.
913
937
  url.searchParams.delete('appSplash')
914
938
  url.searchParams.delete('appCatalog')
939
+ url.searchParams.delete('nativeFirebase')
915
940
  return url.toString()
916
941
  } catch {
917
942
  return fallback
@@ -135,8 +135,10 @@ function renderLauncherScript(
135
135
  const quoted = parts.map(shellSingleQuote).join(' ')
136
136
  const stdout = shellSingleQuote(logDir.stdout)
137
137
  const stderr = shellSingleQuote(logDir.stderr)
138
- const peachHome = process.env.PEACH_HOME
139
- const environment = peachHome ? `export PEACH_HOME=${shellSingleQuote(peachHome)}\n` : ''
138
+ const environment = ['PEACH_HOME', 'PEACH_UPDATE_CHECK']
139
+ .filter((key) => process.env[key])
140
+ .map((key) => `export ${key}=${shellSingleQuote(process.env[key] ?? '')}\n`)
141
+ .join('')
140
142
  return `#!/bin/sh
141
143
  # peach daemon launcher generated by 'peach daemon install'.
142
144
  # launchd spawns this; we exec the real peach invocation in place so
@@ -149,10 +149,10 @@ ${programArgs}
149
149
  function renderSystemdUnit(invocation: RnxSelfInvocation, port: number): string {
150
150
  mkdirSync(LOG_DIR_LINUX, { recursive: true })
151
151
  const exec = [invocation.executable, ...invocation.prefixArgs].map(shellQuote).join(' ')
152
- const peachHome = process.env.PEACH_HOME
153
- const environment = peachHome
154
- ? `Environment=${shellQuote(`PEACH_HOME=${peachHome}`)}\n`
155
- : ''
152
+ const environment = ['PEACH_HOME', 'PEACH_UPDATE_CHECK']
153
+ .filter((key) => process.env[key])
154
+ .map((key) => `Environment=${shellQuote(`${key}=${process.env[key]}`)}\n`)
155
+ .join('')
156
156
  return `[Unit]
157
157
  Description=peach bridge daemon
158
158
  After=default.target
@@ -1,11 +1,10 @@
1
1
  import {
2
2
  devices,
3
- DUO_REST_PROGRESS,
4
3
  getDevice,
5
4
  listSelectableDeviceModels,
6
5
  type DeviceModel,
7
- type DuoFoldRest,
8
- } from 'peach-engine/settings'
6
+ } from 'peach-engine/settings/devices'
7
+ import { DUO_REST_PROGRESS, type DuoFoldRest } from 'peach-engine/settings/duo-fold-bus'
9
8
  import { SOOTSIM_SHELL_COMMAND_NAME } from '../../src/menu'
10
9
  import { rnxExit } from '../run-rnx'
11
10
  import {
@@ -332,6 +332,8 @@ function payloadFromResolved(
332
332
  interface TextCandidate {
333
333
  node: SimSemanticNode
334
334
  ancestorTestIDs: string[]
335
+ actionable: boolean
336
+ role?: string
335
337
  }
336
338
 
337
339
  // a non-pressable node repeating the text of its nearest labelled ancestor,
@@ -343,16 +345,28 @@ function collectTextCandidates(
343
345
  nodes: readonly SimSemanticNode[],
344
346
  ancestors: string[] = [],
345
347
  pressableOwnerText = '',
348
+ actionableAncestor = false,
349
+ actionableRole?: string,
346
350
  ): TextCandidate[] {
347
351
  const out: TextCandidate[] = []
348
352
  for (const node of nodes) {
349
353
  const text = nodeText(node)
350
354
  const caption = !!text && text === pressableOwnerText && !node.pressable
351
- if (!caption) out.push({ node, ancestorTestIDs: ancestors })
355
+ const actionable = !!node.pressable || actionableAncestor
356
+ const role = node.role ?? actionableRole
357
+ if (!caption) out.push({ node, ancestorTestIDs: ancestors, actionable, role })
352
358
  if (node.children?.length) {
353
359
  const next = node.testID ? [...ancestors, node.testID] : ancestors
354
360
  const owner = text ? (node.pressable ? text : '') : pressableOwnerText
355
- out.push(...collectTextCandidates(node.children, next, owner))
361
+ out.push(
362
+ ...collectTextCandidates(
363
+ node.children,
364
+ next,
365
+ owner,
366
+ actionable,
367
+ node.pressable ? node.role : actionableRole,
368
+ ),
369
+ )
356
370
  }
357
371
  }
358
372
  return out
@@ -388,93 +402,112 @@ export async function tapByText(
388
402
  agent: opts.agent,
389
403
  timing: opts.timing,
390
404
  textFallback: query,
391
- resolve: async () => {
392
- const { tree } = await inspectTree(bridge, 50)
393
- if (!Array.isArray(tree)) return { matched: 0, total: 0 }
394
- const needle = options.exact ? query : query.toLowerCase()
395
- let candidates = collectTextCandidates(tree).filter(({ node, ancestorTestIDs }) => {
396
- const text = nodeText(node)
397
- if (!text) return false
398
- if (options.exact ? text !== needle : !text.toLowerCase().includes(needle)) {
399
- return false
400
- }
401
- if (options.role && node.role !== options.role) return false
402
- if (options.within && !ancestorTestIDs.includes(options.within)) return false
403
- const ax = node.geometry.x
404
- const ay = node.geometry.y
405
- if (options.minX != null && !(ax >= options.minX)) return false
406
- if (options.maxX != null && !(ax <= options.maxX)) return false
407
- if (options.minY != null && !(ay >= options.minY)) return false
408
- if (options.maxY != null && !(ay <= options.maxY)) return false
409
- return true
410
- })
405
+ resolve: () => resolveTextTapTarget(bridge, query, options),
406
+ })
407
+ }
411
408
 
412
- if (options.near) {
413
- const { x, y } = options.near
414
- candidates.sort(
415
- (a, b) =>
416
- Math.hypot(a.node.geometry.x - x, a.node.geometry.y - y) -
417
- Math.hypot(b.node.geometry.x - x, b.node.geometry.y - y),
418
- )
419
- } else {
420
- candidates.sort((a, b) => {
421
- if (Math.abs(a.node.geometry.y - b.node.geometry.y) > 2) {
422
- return a.node.geometry.y - b.node.geometry.y
423
- }
424
- return a.node.geometry.x - b.node.geometry.x
425
- })
409
+ async function resolveTextTapTarget(
410
+ bridge: InspectBridge,
411
+ query: string,
412
+ options: TapTextOptions = {},
413
+ ) {
414
+ const { tree } = await inspectTree(bridge, 50)
415
+ if (!Array.isArray(tree)) return { matched: 0, total: 0 }
416
+ const needle = options.exact ? query : query.toLowerCase()
417
+ let candidates = collectTextCandidates(tree).filter(
418
+ ({ node, ancestorTestIDs, role }) => {
419
+ const text = nodeText(node)
420
+ if (!text) return false
421
+ if (options.exact ? text !== needle : !text.toLowerCase().includes(needle)) {
422
+ return false
426
423
  }
424
+ if (options.role && role !== options.role) return false
425
+ if (options.within && !ancestorTestIDs.includes(options.within)) return false
426
+ const ax = node.geometry.x
427
+ const ay = node.geometry.y
428
+ if (options.minX != null && !(ax >= options.minX)) return false
429
+ if (options.maxX != null && !(ax <= options.maxX)) return false
430
+ if (options.minY != null && !(ay >= options.minY)) return false
431
+ if (options.maxY != null && !(ay <= options.maxY)) return false
432
+ return true
433
+ },
434
+ )
427
435
 
428
- const total = candidates.length
429
- if (total === 0) return { matched: 0, total: 0 }
436
+ // default label taps prefer controls to prose mentioning the same word.
437
+ // explicit position selectors keep their full candidate list and order.
438
+ if (options.nth == null && !options.first && !options.near) {
439
+ const actionable = candidates.filter((candidate) => candidate.actionable)
440
+ if (actionable.length > 0) candidates = actionable
441
+ }
430
442
 
431
- let idx = 0
432
- if (options.nth != null) {
433
- idx = options.nth < 0 ? total + options.nth : options.nth
434
- if (idx < 0 || idx >= total) {
435
- return { matched: 0, total, nthOutOfRange: true, nth: options.nth }
436
- }
437
- } else if (total > 1 && !options.first && !options.near) {
438
- return {
439
- ambiguous: true,
440
- total,
441
- candidates: candidates.slice(0, 10).map((candidate, index) => ({
442
- idx: index,
443
- nodeId: candidate.node.nodeId,
444
- type: candidate.node.type,
445
- testID: candidate.node.testID ?? null,
446
- text: nodeText(candidate.node).slice(0, 60),
447
- abs: { x: candidate.node.geometry.x, y: candidate.node.geometry.y },
448
- layout: {
449
- width: Math.round(candidate.node.geometry.width),
450
- height: Math.round(candidate.node.geometry.height),
451
- },
452
- ancestorTestIDs: candidate.ancestorTestIDs.slice(0, 5),
453
- })),
454
- }
443
+ if (options.near) {
444
+ const { x, y } = options.near
445
+ candidates.sort(
446
+ (a, b) =>
447
+ Math.hypot(a.node.geometry.x - x, a.node.geometry.y - y) -
448
+ Math.hypot(b.node.geometry.x - x, b.node.geometry.y - y),
449
+ )
450
+ } else {
451
+ candidates.sort((a, b) => {
452
+ if (Math.abs(a.node.geometry.y - b.node.geometry.y) > 2) {
453
+ return a.node.geometry.y - b.node.geometry.y
455
454
  }
455
+ return a.node.geometry.x - b.node.geometry.x
456
+ })
457
+ }
456
458
 
457
- const picked = candidates[idx]
458
- if (!picked) return { matched: 0, total }
459
- const node = picked.node
460
- let resolved = node.testID
461
- ? await resolveTargetCoords(bridge, { mode: 'testid', value: node.testID })
462
- : await resolveTargetCoords(bridge, {
463
- mode: 'text',
464
- value: nodeText(node) || query,
465
- })
466
- if (!resolved) {
467
- resolved = {
468
- x: node.geometry.x + node.geometry.width / 2,
469
- y: node.geometry.y + node.geometry.height / 2,
470
- testID: node.testID ?? null,
471
- text: nodeText(node) || query,
472
- type: node.type,
473
- }
474
- }
475
- return payloadFromResolved(resolved, node, { total, idx })
476
- },
459
+ const total = candidates.length
460
+ if (total === 0) return { matched: 0, total: 0 }
461
+
462
+ let idx = 0
463
+ if (options.nth != null) {
464
+ idx = options.nth < 0 ? total + options.nth : options.nth
465
+ if (idx < 0 || idx >= total) {
466
+ return { matched: 0, total, nthOutOfRange: true, nth: options.nth }
467
+ }
468
+ } else if (total > 1 && !options.first && !options.near) {
469
+ return {
470
+ ambiguous: true,
471
+ total,
472
+ candidates: candidates.slice(0, 10).map((candidate, index) => ({
473
+ idx: index,
474
+ nodeId: candidate.node.nodeId,
475
+ type: candidate.node.type,
476
+ testID: candidate.node.testID ?? null,
477
+ text: nodeText(candidate.node).slice(0, 60),
478
+ abs: { x: candidate.node.geometry.x, y: candidate.node.geometry.y },
479
+ layout: {
480
+ width: Math.round(candidate.node.geometry.width),
481
+ height: Math.round(candidate.node.geometry.height),
482
+ },
483
+ ancestorTestIDs: candidate.ancestorTestIDs.slice(0, 5),
484
+ })),
485
+ }
486
+ }
487
+
488
+ const picked = candidates[idx]
489
+ if (!picked) return { matched: 0, total }
490
+ const node = picked.node
491
+ // keep the selected handle: re-searching its text or testID can pick a
492
+ // different control, including a covered tenant behind native UI.
493
+ const tap: unknown = await bridge.send({
494
+ type: 'call',
495
+ path: 'SootSim.bridges.test.resolveTapTarget',
496
+ args: [node.nodeId],
477
497
  })
498
+ if (!isTapTargetInfo(tap)) return { matched: 0, total }
499
+ return payloadFromResolved(
500
+ {
501
+ x: tap.cx,
502
+ y: tap.cy,
503
+ nodeId: tap.target.nodeId,
504
+ testID: tap.target.testID,
505
+ text: tap.target.text ?? tap.target.accessibilityLabel,
506
+ type: tap.target.type,
507
+ },
508
+ node,
509
+ { total, idx },
510
+ )
478
511
  }
479
512
 
480
513
  export type TapQuery =
@@ -570,17 +603,19 @@ export async function tapBest(
570
603
  },
571
604
  }
572
605
  }
573
- const byText = await resolveTargetCoords(bridge, { mode: 'text', value: query })
574
- if (byText) {
606
+ const byText = await resolveTextTapTarget(bridge, query)
607
+ if ('ambiguous' in byText) {
608
+ return {
609
+ ambiguous: true,
610
+ error: `multiple text matches for "${query}"; use tap-text with --nth, --within or --near`,
611
+ }
612
+ }
613
+ if ('cx' in byText && typeof byText.cx === 'number') {
575
614
  return {
576
- ...payloadFromResolved(byText, byText),
615
+ ...byText,
577
616
  strategy: 'text',
578
617
  node: {
579
- nodeId: byText.nodeId ?? null,
580
- id: byText.testID ?? null,
581
- testID: byText.testID ?? null,
582
- type: byText.type ?? null,
583
- text: byText.text ?? query,
618
+ ...byText.target,
584
619
  },
585
620
  }
586
621
  }
@@ -544,6 +544,11 @@ export interface LayoutElement {
544
544
  lastLineWords?: number
545
545
  // last line width relative to the widest line (0-1).
546
546
  lastLineWidthRatio?: number
547
+ // first display-size word (24px and up, space-delimited scripts) the browser
548
+ // split across two lines mid-letter. words with a legal break point
549
+ // (hyphen, dash, slash, soft hyphen, zero-width space) and domains or emails
550
+ // are never reported.
551
+ brokenWord?: string
547
552
  ellipsisActive?: boolean
548
553
  }
549
554
 
@@ -192,6 +192,24 @@ async function probeSimResponsive(bridge: WsBridge): Promise<boolean> {
192
192
  }
193
193
  }
194
194
 
195
+ // a bare `do tap <x>` resolves a testID; users coming from maestro's
196
+ // `tapOn: <text>` pass visible text there, so point them at `--text`.
197
+ async function reportMissingTarget(
198
+ bridge: WsBridge,
199
+ verb: 'tap' | 'double-tap' | 'long-press',
200
+ target: { mode: 'testid' | 'text'; value: string },
201
+ ): Promise<void> {
202
+ console.error(` not found: ${target.value}`)
203
+ if (target.mode !== 'testid') return
204
+ const byText = await inspectFind(bridge, { text: target.value }).catch(() => null)
205
+ if (byText?.result) {
206
+ console.error(` that is visible text: peach do ${verb} --text "${target.value}"`)
207
+ return
208
+ }
209
+ await printSimilarTestIds(bridge, target.value)
210
+ maybeHint('wait-selector-for-missing-testid', target.value)
211
+ }
212
+
195
213
  // surface the 5 closest candidates so callers can fix the typo without
196
214
  // dumping the full tree. uses a cheap prefix + Levenshtein score so a
197
215
  // trailing digit or near-miss casing still bubbles to the top.
@@ -1036,7 +1054,12 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1036
1054
  args[0] === 'get' || args[0] === 'do' || args[0] === 'debug' || args[0] === 'wait'
1037
1055
  ? args[0]
1038
1056
  : null
1039
- const effectiveArgs = verbPrefix ? args.slice(1) : args
1057
+ const groupedArgs = verbPrefix ? args.slice(1) : args
1058
+ const tapTarget = groupedArgs[0] === 'tap' ? readTargetFlag(groupedArgs) : null
1059
+ const effectiveArgs =
1060
+ tapTarget?.mode === 'text'
1061
+ ? ['tap-text', tapTarget.value, ...groupedArgs.slice(1)]
1062
+ : groupedArgs
1040
1063
 
1041
1064
  const parsed = parseBridgeCliArgs(effectiveArgs, {
1042
1065
  port: opts.port,
@@ -1999,10 +2022,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1999
2022
  })
2000
2023
  const payload = outcome.payload
2001
2024
  if (!payload || typeof payload.cx !== 'number') {
2002
- console.error(` not found: ${target.value}`)
2003
- if (target.mode === 'testid') {
2004
- maybeHint('wait-selector-for-missing-testid', target.value)
2005
- }
2025
+ await reportMissingTarget(bridge, 'tap', target)
2006
2026
  rnxExit(1)
2007
2027
  }
2008
2028
  if (!isTapSuccess(outcome.result)) {
@@ -2773,10 +2793,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2773
2793
  if (target) {
2774
2794
  const resolved = await resolveTargetCoords(bridge, target)
2775
2795
  if (!resolved) {
2776
- console.error(` not found: ${target.value}`)
2777
- if (target.mode === 'testid') {
2778
- maybeHint('wait-selector-for-missing-testid', target.value)
2779
- }
2796
+ await reportMissingTarget(bridge, 'double-tap', target)
2780
2797
  rnxExit(1)
2781
2798
  }
2782
2799
  x = resolved.x
@@ -2816,10 +2833,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2816
2833
  if (target) {
2817
2834
  const resolved = await resolveTargetCoords(bridge, target)
2818
2835
  if (!resolved) {
2819
- console.error(` not found: ${target.value}`)
2820
- if (target.mode === 'testid') {
2821
- maybeHint('wait-selector-for-missing-testid', target.value)
2822
- }
2836
+ await reportMissingTarget(bridge, 'long-press', target)
2823
2837
  rnxExit(1)
2824
2838
  }
2825
2839
  x = resolved.x
@@ -12,7 +12,7 @@ import {
12
12
  listSelectableDeviceModels,
13
13
  type DeviceModel,
14
14
  type RuntimePlatform,
15
- } from 'peach-engine/settings'
15
+ } from 'peach-engine/settings/devices'
16
16
  import { settingsStore } from 'peach-engine/settings/store'
17
17
  import { rnxPublicBrand } from '../../src/public-brand'
18
18
  import { UnsupportedRemoteHostError } from '../cloud-client'
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { mkdirSync, writeFileSync } from 'fs'
11
11
  import { resolve } from 'path'
12
- import { devices } from 'peach-engine/settings'
12
+ import { devices } from 'peach-engine/settings/devices'
13
13
  import { buildResolveRectEval, type SampleRect } from '../browser-evals'
14
14
  import { rnxExit } from '../run-rnx'
15
15
  import { callInBridge, createBridgeFromParsed, parseBridgeCliArgs } from '../ws-bridge'
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { mkdirSync, writeFileSync } from 'fs'
10
10
  import { dirname, resolve } from 'path'
11
- import { devices, type DeviceModel } from 'peach-engine/settings'
11
+ import { devices, type DeviceModel } from 'peach-engine/settings/devices'
12
12
  import { launchReapedChrome } from '../../../../scripts/lib/reap-browser'
13
13
  import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
14
14
  import { composeFramedScreenshot } from '../../src/screenshots/frame-compose'