peachsim 0.1.629 → 0.1.631

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 (128) hide show
  1. package/README.md +8 -4
  2. package/cli/app-fonts.ts +23 -4
  3. package/cli/bridge-flow-runner.ts +45 -31
  4. package/cli/cli-update.ts +2 -7
  5. package/cli/cloud-test-tunnel.ts +16 -3
  6. package/cli/command-registry.ts +1 -0
  7. package/cli/commands/compat.ts +11 -60
  8. package/cli/commands/config.ts +1 -1
  9. package/cli/commands/control.ts +40 -8
  10. package/cli/commands/daemon-mac-app.ts +4 -2
  11. package/cli/commands/daemon.ts +4 -4
  12. package/cli/commands/detect.ts +289 -26
  13. package/cli/commands/device.ts +2 -3
  14. package/cli/commands/inspect/actions.ts +125 -90
  15. package/cli/commands/inspect/core.ts +1 -1
  16. package/cli/commands/inspect.ts +10 -1
  17. package/cli/commands/maestro.ts +3 -1
  18. package/cli/commands/platform.ts +1 -1
  19. package/cli/commands/project-report.ts +386 -0
  20. package/cli/commands/projectReportContract.ts +42 -0
  21. package/cli/commands/report-issue.ts +38 -54
  22. package/cli/commands/screenshot-layers.ts +1 -1
  23. package/cli/commands/screenshot.ts +1 -1
  24. package/cli/commands/setup.ts +6 -4
  25. package/cli/commands/skills.ts +0 -31
  26. package/cli/commands/telemetry.ts +8 -3
  27. package/cli/commands/test.ts +347 -134
  28. package/cli/drivers/playwright-provisioning.ts +3 -2
  29. package/cli/drivers/playwright-sim-host.ts +25 -9
  30. package/cli/main.ts +19 -3
  31. package/cli/outbound-endpoints.ts +2 -5
  32. package/cli/privacy.ts +1 -2
  33. package/cli/runtime-diagnostics.ts +51 -0
  34. package/cli/telemetry.ts +78 -7
  35. package/detox/index.ts +77 -51
  36. package/dist-lib/agent-daemon-client.cjs +1 -1
  37. package/dist-lib/agent-events.cjs +1 -1
  38. package/dist-lib/agent-identity.cjs +1 -1
  39. package/dist-lib/agent-sessions.cjs +1 -1
  40. package/dist-lib/attached-projects.cjs +1 -1
  41. package/dist-lib/auth/shared-session.cjs +1 -1
  42. package/dist-lib/backend-origin.cjs +1 -1
  43. package/dist-lib/beta.cjs +1 -1
  44. package/dist-lib/beta.mjs +1 -1
  45. package/dist-lib/bridge-constants.cjs +1 -1
  46. package/dist-lib/bridge-contract-input.cjs +1 -1
  47. package/dist-lib/bridge-contract-input.mjs +1 -1
  48. package/dist-lib/bridge-contract.cjs +1 -1
  49. package/dist-lib/bridge-contract.mjs +1 -1
  50. package/dist-lib/capture-contract.cjs +1 -1
  51. package/dist-lib/capture-contract.mjs +1 -1
  52. package/dist-lib/cli-constants.cjs +1 -1
  53. package/dist-lib/cloud-contract.cjs +1 -1
  54. package/dist-lib/cloud-contract.mjs +1 -1
  55. package/dist-lib/cloud-test-args.cjs +1 -1
  56. package/dist-lib/cloud-test-args.mjs +1 -1
  57. package/dist-lib/cloud.cjs +1 -1
  58. package/dist-lib/cloud.mjs +1 -1
  59. package/dist-lib/config.cjs +1 -1
  60. package/dist-lib/detox/index.cjs +130 -33
  61. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  62. package/dist-lib/home-paths.cjs +8 -5
  63. package/dist-lib/host/bridge-host.cjs +353 -290
  64. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  65. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  66. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  67. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  68. package/dist-lib/host/websocket-proxy.cjs +1 -1
  69. package/dist-lib/index.cjs +257 -121
  70. package/dist-lib/jump-to-source-babel.cjs +1 -1
  71. package/dist-lib/jump-to-source-native.cjs +1 -1
  72. package/dist-lib/{vite-kotlin.cjs → kotlin-host.cjs} +130 -19
  73. package/dist-lib/menu.cjs +11 -1
  74. package/dist-lib/menu.mjs +11 -1
  75. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  76. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  77. package/dist-lib/metro-production-bundle.cjs +1 -1
  78. package/dist-lib/metro-production-bundle.mjs +1 -1
  79. package/dist-lib/metro.cjs +132 -9
  80. package/dist-lib/native-app-identity.cjs +278 -0
  81. package/dist-lib/native-app-identity.mjs +245 -0
  82. package/dist-lib/profiles.cjs +1 -1
  83. package/dist-lib/public-brand.cjs +1 -1
  84. package/dist-lib/react-native-host-modules.cjs +1 -1
  85. package/dist-lib/react-native-host-modules.mjs +1 -1
  86. package/dist-lib/render-mode.cjs +1 -1
  87. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  88. package/dist-lib/sdk.cjs +112 -87
  89. package/dist-lib/sdk.mjs +111 -87
  90. package/dist-lib/skills.cjs +1270 -709
  91. package/dist-lib/swift-compile.cjs +1 -1
  92. package/dist-lib/swift-compile.mjs +1 -1
  93. package/dist-lib/swift-package.cjs +1 -1
  94. package/dist-lib/swift-package.mjs +1 -1
  95. package/dist-lib/swift-submit.cjs +1 -1
  96. package/dist-lib/swift-submit.mjs +1 -1
  97. package/dist-lib/swift.cjs +672 -275
  98. package/dist-lib/vite-swift.cjs +1 -1
  99. package/dist-lib/vite.cjs +11 -28
  100. package/kotlin/README.md +32 -4
  101. package/kotlin/fixtures/counter/Counter.kt +26 -0
  102. package/kotlin/fixtures/counter/CounterScreen.kt +40 -0
  103. package/kotlin/fixtures/headless-counter/Headless.kt +228 -0
  104. package/kotlin/fixtures/runtime-state/RuntimeState.kt +200 -0
  105. package/kotlin/fork/LICENSE +178 -0
  106. package/kotlin/fork/NOTICE +25 -0
  107. package/kotlin/fork/README.md +59 -0
  108. package/kotlin/fork/build.sh +128 -0
  109. package/kotlin/fork/compose-worker-globals.patch +96 -0
  110. package/kotlin/fork/fork.json +33 -0
  111. package/kotlin/fork/publish.ts +77 -0
  112. package/kotlin/fork/skiko-worker-clock.patch +33 -0
  113. package/kotlin/toolchain.json +288 -0
  114. package/package.json +13 -5
  115. package/skills/contrast/SKILL.md +1 -0
  116. package/src/automatic-update-policy.ts +17 -0
  117. package/src/home-paths.ts +11 -9
  118. package/src/host/bridge-host.ts +3 -1
  119. package/src/{vite-plugin-kotlin.ts → kotlin-host.ts} +155 -12
  120. package/src/menu.ts +11 -0
  121. package/src/metro-plugin.ts +18 -0
  122. package/src/native-app-identity.ts +376 -0
  123. package/src/native-firebase-config.ts +90 -0
  124. package/src/screenshots/frame-compose.ts +1 -1
  125. package/src/screenshots/registry.ts +1 -1
  126. package/src/screenshots/schema.ts +2 -2
  127. package/src/vite-plugin-one.ts +11 -28
  128. package/src/vite-plugin.ts +6 -1
@@ -1,11 +1,21 @@
1
1
  // project detection — finds project root, detects framework, scans monorepo workspaces
2
2
 
3
- import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
4
- import { basename, dirname, join } from 'path'
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ opendirSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ statSync,
10
+ } from 'node:fs'
11
+ import { basename, dirname, join } from 'node:path'
12
+ import JSON5 from 'json5'
13
+ import { parse as parseYaml } from 'yaml'
14
+ import type { DependencyVersion, ProjectManifest } from './projectReportContract'
5
15
 
6
16
  export type Framework = 'one' | 'expo' | 'bare' | 'unknown'
7
17
  export type PackageManager = 'bun' | 'pnpm' | 'npm' | 'yarn'
8
- export type MonorepoType = 'turbo' | 'pnpm' | 'nx' | 'npm-workspaces' | 'single'
18
+ export type MonorepoType = 'turbo' | 'pnpm' | 'nx' | 'lerna' | 'npm-workspaces' | 'single'
9
19
 
10
20
  export interface DetectedProject {
11
21
  dir: string
@@ -25,7 +35,8 @@ export interface DetectedWorkspace {
25
35
 
26
36
  const CONFIG_EXTENSIONS = ['ts', 'js', 'cjs', 'mjs', 'mts', 'cts']
27
37
 
28
- export function findProjectRoot(cwd: string): string {
38
+ export function findProjectRoot(cwd: string, includeNativeRepository = false): string {
39
+ let swiftRoot: string | undefined
29
40
  let dir = cwd
30
41
  while (true) {
31
42
  if (
@@ -37,22 +48,30 @@ export function findProjectRoot(cwd: string): string {
37
48
  return dir
38
49
  }
39
50
 
40
- const pkgPath = join(dir, 'package.json')
41
- if (existsSync(pkgPath)) {
42
- try {
43
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
44
- if (pkg.workspaces) return dir
45
- } catch {}
46
- }
51
+ if (metadataValue(readProjectJson(join(dir, 'package.json')), 'workspaces'))
52
+ return dir
47
53
 
54
+ if (includeNativeRepository) {
55
+ if (existsSync(join(dir, '.git'))) return dir
56
+ if (!swiftRoot && existsSync(join(dir, 'Package.swift'))) swiftRoot = dir
57
+ }
48
58
  const parent = dirname(dir)
49
59
  if (parent === dir) break
50
60
  dir = parent
51
61
  }
52
- return cwd
62
+ return swiftRoot ?? cwd
53
63
  }
54
64
 
55
65
  export function detectPackageManager(root: string): PackageManager {
66
+ const manager = metadataValue(
67
+ readProjectJson(join(root, 'package.json')),
68
+ 'packageManager',
69
+ )
70
+ if (typeof manager === 'string') {
71
+ const name = manager.split('@')[0]
72
+ if (name === 'bun' || name === 'pnpm' || name === 'npm' || name === 'yarn')
73
+ return name
74
+ }
56
75
  if (existsSync(join(root, 'bun.lock')) || existsSync(join(root, 'bun.lockb'))) {
57
76
  return 'bun'
58
77
  }
@@ -66,13 +85,9 @@ export function detectMonorepoType(root: string): MonorepoType {
66
85
  if (existsSync(join(root, 'nx.json'))) return 'nx'
67
86
  if (existsSync(join(root, 'pnpm-workspace.yaml'))) return 'pnpm'
68
87
 
69
- const pkgPath = join(root, 'package.json')
70
- if (existsSync(pkgPath)) {
71
- try {
72
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
73
- if (pkg.workspaces) return 'npm-workspaces'
74
- } catch {}
75
- }
88
+ if (existsSync(join(root, 'lerna.json'))) return 'lerna'
89
+ if (metadataValue(readProjectJson(join(root, 'package.json')), 'workspaces'))
90
+ return 'npm-workspaces'
76
91
  return 'single'
77
92
  }
78
93
 
@@ -87,14 +102,14 @@ export function detectProject(dir: string): DetectedProject | null {
87
102
  const pkgPath = join(dir, 'package.json')
88
103
  if (!existsSync(pkgPath)) return null
89
104
 
90
- let pkg: any
91
- try {
92
- pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
93
- } catch {
94
- return null
95
- }
105
+ const pkg = readProjectManifest(dir)
106
+ if (!pkg) return null
96
107
 
97
108
  const deps = { ...pkg.dependencies, ...pkg.devDependencies }
109
+ const devCommand = metadataValue(
110
+ metadataValue(readProjectJson(pkgPath), 'scripts'),
111
+ 'dev',
112
+ )
98
113
  const framework = detectFramework(deps)
99
114
 
100
115
  if (framework === 'unknown') return null
@@ -112,7 +127,7 @@ export function detectProject(dir: string): DetectedProject | null {
112
127
  hasMetroConfig: CONFIG_EXTENSIONS.some((ext) =>
113
128
  existsSync(join(dir, `metro.config.${ext}`)),
114
129
  ),
115
- devCommand: pkg.scripts?.dev || null,
130
+ devCommand: typeof devCommand === 'string' ? devCommand : null,
116
131
  }
117
132
  }
118
133
 
@@ -199,3 +214,251 @@ export function scanWorkspace(root: string): DetectedWorkspace {
199
214
 
200
215
  return { root, type, packageManager, apps }
201
216
  }
217
+
218
+ // reports inspect metadata without evaluating app config or following directory
219
+ // symlinks. this also finds native projects outside package-manager workspaces.
220
+ const REPORT_SKIP_DIRS = new Set([
221
+ 'node_modules',
222
+ 'Pods',
223
+ 'Carthage',
224
+ 'build',
225
+ 'dist',
226
+ 'dist-lib',
227
+ 'DerivedData',
228
+ 'vendor',
229
+ 'coverage',
230
+ 'target',
231
+ 'tmp',
232
+ 'cache',
233
+ 'artifacts',
234
+ 'test-results',
235
+ 'playwright-report',
236
+ ])
237
+
238
+ export function scanRepository(root: string): {
239
+ directories: string[]
240
+ truncated: boolean
241
+ unreadable: number
242
+ } {
243
+ const directories: string[] = []
244
+ const pending = [{ dir: root, depth: 0 }]
245
+ let entries = 0
246
+ let cursor = 0
247
+ let truncated = false
248
+ let unreadable = 0
249
+ while (cursor < pending.length && entries < 200_000 && directories.length < 10_000) {
250
+ const next = pending[cursor++]
251
+ if (!next) break
252
+ directories.push(next.dir)
253
+ let handle: ReturnType<typeof opendirSync>
254
+ try {
255
+ handle = opendirSync(next.dir)
256
+ } catch {
257
+ unreadable++
258
+ continue
259
+ }
260
+ try {
261
+ let entry
262
+ while (entries < 200_000 && (entry = handle.readSync())) {
263
+ entries++
264
+ if (
265
+ !entry.isDirectory() ||
266
+ entry.name.startsWith('.') ||
267
+ REPORT_SKIP_DIRS.has(entry.name)
268
+ )
269
+ continue
270
+ if (next.depth >= 12) {
271
+ truncated = true
272
+ continue
273
+ }
274
+ pending.push({ dir: join(next.dir, entry.name), depth: next.depth + 1 })
275
+ }
276
+ } finally {
277
+ handle.closeSync()
278
+ }
279
+ }
280
+ return {
281
+ directories: directories.sort(),
282
+ truncated: truncated || cursor < pending.length || entries >= 200_000,
283
+ unreadable,
284
+ }
285
+ }
286
+
287
+ // inspect only regular, bounded metadata files. errors are data, never a reason
288
+ // to execute project code. symlinks may point outside the checkout.
289
+ export function readProjectMetadata(path: string): string | null {
290
+ try {
291
+ const info = lstatSync(path)
292
+ if (!info.isFile() || info.size > 4 * 1024 * 1024) return null
293
+ return readFileSync(path, 'utf8')
294
+ } catch {
295
+ return null
296
+ }
297
+ }
298
+
299
+ export function metadataValue(value: unknown, key: string): unknown {
300
+ if (!value || typeof value !== 'object') return undefined
301
+ return Object.getOwnPropertyDescriptor(value, key)?.value
302
+ }
303
+
304
+ export function readProjectJson(path: string): unknown {
305
+ const text = readProjectMetadata(path)
306
+ if (text === null) return null
307
+ try {
308
+ const value: unknown = JSON.parse(text)
309
+ return value
310
+ } catch {
311
+ return null
312
+ }
313
+ }
314
+
315
+ export function readProjectManifest(dir: string): ProjectManifest | null {
316
+ const parsed = readProjectJson(join(dir, 'package.json'))
317
+ if (!parsed || typeof parsed !== 'object') return null
318
+ const record = (value: unknown): Record<string, string> => {
319
+ if (!value || typeof value !== 'object') return {}
320
+ return Object.fromEntries(
321
+ Object.entries(value).filter(
322
+ (entry): entry is [string, string] =>
323
+ typeof entry[1] === 'string' &&
324
+ /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i.test(entry[0]) &&
325
+ entry[0] !== '.' &&
326
+ entry[0] !== '..',
327
+ ),
328
+ )
329
+ }
330
+ const name = metadataValue(parsed, 'name')
331
+ return {
332
+ ...(typeof name === 'string' ? { name } : {}),
333
+ dependencies: record(metadataValue(parsed, 'dependencies')),
334
+ devDependencies: record(metadataValue(parsed, 'devDependencies')),
335
+ }
336
+ }
337
+
338
+ // resolve each dependency at its nearest installation/lockfile boundary, so an
339
+ // uninstalled nested project never inherits another app's hoisted version.
340
+ export function resolveManifestVersions(
341
+ dir: string,
342
+ manifest: ProjectManifest,
343
+ locks = new Map<string, Map<string, string>>(),
344
+ ): Record<string, DependencyVersion> {
345
+ const resolveVersion = (name: string, declared: string): DependencyVersion => {
346
+ if (!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i.test(name))
347
+ return { version: declared, source: 'declared' }
348
+ let current = dir
349
+ while (true) {
350
+ // installed packages commonly use symlinks; read just this known manifest,
351
+ // never traverse their dependency trees.
352
+ try {
353
+ const file = join(current, 'node_modules', name, 'package.json')
354
+ if (statSync(file).size <= 256 * 1024) {
355
+ const value: unknown = JSON.parse(readFileSync(file, 'utf8'))
356
+ const version = metadataValue(value, 'version')
357
+ if (typeof version === 'string') return { version, source: 'installed' }
358
+ }
359
+ } catch {}
360
+ let lock = locks.get(current)
361
+ if (!lock) {
362
+ lock = readLockedVersions(current)
363
+ locks.set(current, lock)
364
+ }
365
+ const version = lock.get(name)
366
+ if (version) return { version, source: 'lockfile' }
367
+ if (
368
+ [
369
+ 'bun.lock',
370
+ 'bun.lockb',
371
+ 'package-lock.json',
372
+ 'pnpm-lock.yaml',
373
+ 'yarn.lock',
374
+ ].some((file) => existsSync(join(current, file)))
375
+ )
376
+ break
377
+ const parent = dirname(current)
378
+ if (parent === current) break
379
+ current = parent
380
+ }
381
+ return { version: declared, source: 'declared' }
382
+ }
383
+ return Object.fromEntries(
384
+ Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }).map(
385
+ ([name, declared]) => [name, resolveVersion(name, declared)],
386
+ ),
387
+ )
388
+ }
389
+
390
+ function readLockedVersions(dir: string): Map<string, string> {
391
+ const versions = new Map<string, string>()
392
+ const put = (name: string, value: unknown) => {
393
+ if (typeof value !== 'string') return
394
+ // multiple resolutions without an installed tree are ambiguous; report the
395
+ // declared range rather than choosing a transitive package's version.
396
+ const before = versions.get(name)
397
+ versions.set(name, before === undefined || before === value ? value : '')
398
+ }
399
+ const bun = readProjectMetadata(join(dir, 'bun.lock'))
400
+ if (bun) {
401
+ try {
402
+ const rootVersions = new Map<string, string>()
403
+ const parsed: unknown = JSON5.parse(bun)
404
+ const packages = metadataValue(parsed, 'packages')
405
+ if (packages && typeof packages === 'object') {
406
+ for (const [key, tuple] of Object.entries(packages)) {
407
+ if (!Array.isArray(tuple) || typeof tuple[0] !== 'string') continue
408
+ const match = /^((?:@[^/]+\/)?[^@]+)@([\d][\w.+-]*)$/.exec(tuple[0])
409
+ if (match) put(match[1], match[2])
410
+ // bun's root resolution wins over nested duplicate package keys.
411
+ if (match && key === match[1]) rootVersions.set(match[1], match[2])
412
+ }
413
+ }
414
+ for (const [name, version] of rootVersions) versions.set(name, version)
415
+ } catch {}
416
+ }
417
+ const npm = metadataValue(readProjectJson(join(dir, 'package-lock.json')), 'packages')
418
+ if (npm && typeof npm === 'object') {
419
+ for (const [key, value] of Object.entries(npm)) {
420
+ if (key.startsWith('node_modules/') && !key.slice(13).includes('/node_modules/'))
421
+ put(key.slice(13), metadataValue(value, 'version'))
422
+ }
423
+ }
424
+ const pnpm = readProjectMetadata(join(dir, 'pnpm-lock.yaml'))
425
+ if (pnpm) {
426
+ try {
427
+ const parsed: unknown = parseYaml(pnpm)
428
+ const packages = metadataValue(parsed, 'packages')
429
+ if (packages && typeof packages === 'object')
430
+ for (const key of Object.keys(packages)) {
431
+ const match = /^\/?((?:@[^/]+\/)?[^@]+)@([\d][\w.+-]*)/.exec(key)
432
+ if (match) put(match[1], match[2])
433
+ }
434
+ } catch {}
435
+ }
436
+ const yarn = readProjectMetadata(join(dir, 'yarn.lock'))
437
+ if (yarn) {
438
+ let names: string[] = []
439
+ for (const line of yarn.split('\n')) {
440
+ if (/^\S.*:\s*$/.test(line))
441
+ names = [...line.matchAll(/(?:^|[", ]+)((?:@[^/\s]+\/)?[^@" ,]+)@/g)].map(
442
+ (match) => match[1],
443
+ )
444
+ const version = /^\s+version[ :]+["']?([\d][\w.+-]*)/.exec(line)
445
+ if (version) for (const name of names) put(name, version[1])
446
+ }
447
+ }
448
+ return versions
449
+ }
450
+
451
+ export function installedManifest(
452
+ dir: string,
453
+ manifest: ProjectManifest,
454
+ ): ProjectManifest {
455
+ const versions = resolveManifestVersions(dir, manifest)
456
+ const mapped = (deps: Record<string, string> | undefined) =>
457
+ deps &&
458
+ Object.fromEntries(Object.keys(deps).map((name) => [name, versions[name].version]))
459
+ return {
460
+ name: manifest.name,
461
+ dependencies: mapped(manifest.dependencies),
462
+ devDependencies: mapped(manifest.devDependencies),
463
+ }
464
+ }
@@ -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
  }
@@ -1287,7 +1287,7 @@ export interface ConsoleEntry {
1287
1287
  // One root error boundary in the tenant worker) can be live and screaming in
1288
1288
  // the driver's console while the agent's `sootsim_errors` reports nothing.
1289
1289
  // Merge both, dedup, so worker + root-error-boundary failures always surface.
1290
- function consoleEntriesEval(level: 'error' | 'warn', limit: number): string {
1290
+ export function consoleEntriesEval(level: 'error' | 'warn', limit: number): string {
1291
1291
  const getter = level === 'error' ? 'getErrors' : 'getWarnings'
1292
1292
  return `(() => {
1293
1293
  const out = []