peachsim 0.1.657 → 0.1.659

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 (115) hide show
  1. package/README.md +21 -2
  2. package/cli/app-config.ts +25 -9
  3. package/cli/bridge-flow-runner.ts +10 -23
  4. package/cli/commands/compat.ts +234 -10
  5. package/cli/commands/do-chain.ts +5 -1
  6. package/cli/commands/inspect/actions.ts +33 -76
  7. package/cli/commands/inspect/resolve-target.ts +67 -2
  8. package/cli/commands/inspect.ts +16 -1
  9. package/cli/drivers/playwright-provisioning.ts +95 -0
  10. package/cli/ws-bridge.ts +7 -7
  11. package/dist-lib/agent-daemon-client.cjs +1 -1
  12. package/dist-lib/agent-events.cjs +1 -1
  13. package/dist-lib/agent-identity.cjs +1 -1
  14. package/dist-lib/agent-sessions.cjs +1 -1
  15. package/dist-lib/attached-projects.cjs +1 -1
  16. package/dist-lib/auth/shared-session.cjs +1 -1
  17. package/dist-lib/backend-origin.cjs +1 -1
  18. package/dist-lib/beta.cjs +1 -1
  19. package/dist-lib/beta.mjs +1 -1
  20. package/dist-lib/bridge-constants.cjs +1 -1
  21. package/dist-lib/bridge-contract-input.cjs +9 -1
  22. package/dist-lib/bridge-contract-input.mjs +9 -1
  23. package/dist-lib/bridge-contract.cjs +1 -1
  24. package/dist-lib/bridge-contract.mjs +1 -1
  25. package/dist-lib/capture-contract.cjs +1 -1
  26. package/dist-lib/capture-contract.mjs +1 -1
  27. package/dist-lib/cli-constants.cjs +1 -1
  28. package/dist-lib/cloud-contract.cjs +1 -1
  29. package/dist-lib/cloud-contract.mjs +1 -1
  30. package/dist-lib/cloud-test-args.cjs +1 -1
  31. package/dist-lib/cloud-test-args.mjs +1 -1
  32. package/dist-lib/cloud.cjs +1 -1
  33. package/dist-lib/cloud.mjs +1 -1
  34. package/dist-lib/config.cjs +3 -2
  35. package/dist-lib/detox/index.cjs +2 -2
  36. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  37. package/dist-lib/home-paths.cjs +1 -1
  38. package/dist-lib/host/bridge-host.cjs +78 -35
  39. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  40. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  41. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  42. package/dist-lib/host/replacement-module-handler.cjs +51 -8
  43. package/dist-lib/host/websocket-proxy.cjs +1 -1
  44. package/dist-lib/index.cjs +317 -70
  45. package/dist-lib/jump-to-source-babel.cjs +1 -1
  46. package/dist-lib/jump-to-source-native.cjs +1 -1
  47. package/dist-lib/kotlin-host.cjs +14 -4
  48. package/dist-lib/menu.cjs +1 -1
  49. package/dist-lib/menu.mjs +1 -1
  50. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  51. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  52. package/dist-lib/metro-production-bundle.cjs +1 -1
  53. package/dist-lib/metro-production-bundle.mjs +1 -1
  54. package/dist-lib/metro.cjs +300 -29
  55. package/dist-lib/native-app-identity.cjs +58 -25
  56. package/dist-lib/native-app-identity.mjs +57 -25
  57. package/dist-lib/plist.cjs +64 -0
  58. package/dist-lib/plist.mjs +29 -0
  59. package/dist-lib/profiles.cjs +1 -1
  60. package/dist-lib/public-brand.cjs +1 -1
  61. package/dist-lib/react-native-host-modules.cjs +1 -1
  62. package/dist-lib/react-native-host-modules.mjs +1 -1
  63. package/dist-lib/render-mode.cjs +1 -1
  64. package/dist-lib/scripts/dev-server-scanner.cjs +2 -2
  65. package/dist-lib/sdk.cjs +36 -49
  66. package/dist-lib/sdk.mjs +36 -49
  67. package/dist-lib/skills.cjs +257 -138
  68. package/dist-lib/swift-compile.cjs +1 -1
  69. package/dist-lib/swift-compile.mjs +1 -1
  70. package/dist-lib/swift-package.cjs +1 -1
  71. package/dist-lib/swift-package.mjs +1 -1
  72. package/dist-lib/swift-submit.cjs +1 -1
  73. package/dist-lib/swift-submit.mjs +1 -1
  74. package/dist-lib/swift.cjs +1 -1
  75. package/dist-lib/vite-swift.cjs +1 -1
  76. package/dist-lib/vite.cjs +51 -8
  77. package/kotlin/AppSources/android/app/Activity.kt +44 -0
  78. package/kotlin/AppSources/android/app/UiModeManager.kt +9 -0
  79. package/kotlin/AppSources/android/content/Context.kt +9 -2
  80. package/kotlin/AppSources/android/content/res/Configuration.kt +16 -0
  81. package/kotlin/AppSources/android/content/res/Resources.kt +7 -0
  82. package/kotlin/AppSources/android/graphics/Color.kt +13 -0
  83. package/kotlin/AppSources/android/os/Bundle.kt +9 -3
  84. package/kotlin/AppSources/android/view/View.kt +2 -0
  85. package/kotlin/AppSources/android/view/Window.kt +99 -8
  86. package/kotlin/AppSources/android/view/WindowInsetsController.kt +12 -0
  87. package/kotlin/AppSources/android/view/WindowManager.kt +16 -0
  88. package/kotlin/AppSources/androidx/activity/BackEventCompat.kt +17 -0
  89. package/kotlin/AppSources/androidx/activity/Cancellable.kt +5 -0
  90. package/kotlin/AppSources/androidx/activity/ComponentActivity.kt +22 -3
  91. package/kotlin/AppSources/androidx/activity/EdgeToEdge.kt +71 -0
  92. package/kotlin/AppSources/androidx/activity/OnBackPressedCallback.kt +28 -0
  93. package/kotlin/AppSources/androidx/activity/OnBackPressedDispatcher.kt +144 -0
  94. package/kotlin/AppSources/androidx/activity/OnBackPressedDispatcherOwner.kt +7 -0
  95. package/kotlin/AppSources/androidx/activity/compose/BackHandler.kt +55 -0
  96. package/kotlin/AppSources/androidx/core/view/WindowCompat.kt +32 -0
  97. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +67 -12
  98. package/kotlin/AppSources/dev/peach/app/PeachWindowHost.kt +2 -1
  99. package/kotlin/AppSources/java/lang/Runnable.kt +6 -0
  100. package/kotlin/ComposeSources/PeachComposeHost.kt +11 -1
  101. package/package.json +8 -1
  102. package/skills/peach-setup/SKILL.md +27 -0
  103. package/src/android-theme.ts +167 -0
  104. package/src/android-xml.ts +70 -0
  105. package/src/bridge-contract-input.ts +10 -1
  106. package/src/bridge-contract.ts +7 -1
  107. package/src/config.ts +8 -0
  108. package/src/generated/android-framework-themes.json +6303 -0
  109. package/src/host/replacement-module-handler.ts +33 -0
  110. package/src/index.ts +1 -0
  111. package/src/kotlin-app.ts +86 -61
  112. package/src/kotlin-host.ts +13 -3
  113. package/src/metro-plugin.ts +46 -3
  114. package/src/native-app-identity.ts +75 -26
  115. package/src/sim-client.ts +2 -4
package/README.md CHANGED
@@ -243,7 +243,22 @@ Windows use Playwright's Chromium headless shell instead: the same build and
243
243
  software renderer without the desktop browser's printing and service discovery
244
244
  libraries (`libcups2`, `libavahi-client3`). Headed launches and every launch on
245
245
  a Mac use full Chromium. When the needed browser is not installed, the CLI runs
246
- the resolved Playwright core's absolute installer entry for just that browser. Later opens reuse that sim and
246
+ the resolved Playwright core's absolute installer entry for just that browser.
247
+ On Linux the CLI checks the browser's shared libraries before launching and,
248
+ when any are missing, exits naming them and the exact `apt-get install` line.
249
+ The headless shell needs these Debian 12 packages (Ubuntu 24.04 and Debian 13
250
+ add a `t64` suffix to `libglib2.0-0`, `libatk1.0-0`, `libatk-bridge2.0-0`,
251
+ `libatspi2.0-0` and `libasound2`):
252
+
253
+ ```sh
254
+ apt-get install --no-install-recommends libglib2.0-0 libnspr4 libnss3 \
255
+ libatk1.0-0 libatk-bridge2.0-0 libatspi2.0-0 libdbus-1-3 libx11-6 libxcb1 \
256
+ libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libexpat1 \
257
+ libxkbcommon0 libudev1 libasound2
258
+ ```
259
+
260
+ Headed launches on Linux use full Chromium, which also needs `libcups2`,
261
+ `libcairo2` and `libpango-1.0-0`. Later opens reuse that sim and
247
262
  reload it at the requested target. Use `peach do reload` when the target has not
248
263
  changed.
249
264
  Reserve `--new` for a genuinely concurrent sim; repeated new browser trees
@@ -628,7 +643,11 @@ serves its selected version from a version-specific localhost origin, so a
628
643
  background activation cannot mix an already loaded shell with chunks from the
629
644
  next runtime. Without config, each new open selects the active stable version.
630
645
  When `peach.config.ts` sets `runtimeVersion`, `peach open` installs that
631
- version without changing the machine default. App config, native-linked fonts,
646
+ version without changing the machine default. The same file replaces an app's own
647
+ native modules (`nativeModules`, `turboModules`) and native views
648
+ (`nativeViews`, keyed by the `requireNativeComponent` name or an Expo view's
649
+ `Module_View` name) with project-local files; the configuration guide in the
650
+ docs lists every slot. App config, native-linked fonts,
632
651
  and splash assets all resolve from the project root advertised by the selected
633
652
  development server. Bare Metro, which has no Expo manifest project root, uses
634
653
  the listening development process's cwd inside that same resolver. The invoking
package/cli/app-config.ts CHANGED
@@ -27,6 +27,7 @@ function isPeachConfig(value: unknown): value is PeachConfig {
27
27
  isOptionalRecord(Reflect.get(value, 'modules')) &&
28
28
  isOptionalRecord(Reflect.get(value, 'turboModules')) &&
29
29
  isOptionalRecord(Reflect.get(value, 'nativeModules')) &&
30
+ isOptionalRecord(Reflect.get(value, 'nativeViews')) &&
30
31
  isOptionalRecord(Reflect.get(value, 'env')) &&
31
32
  isOptionalRecord(network) &&
32
33
  isOptionalRecord(Reflect.get(value, 'settings')) &&
@@ -34,22 +35,37 @@ function isPeachConfig(value: unknown): value is PeachConfig {
34
35
  )
35
36
  }
36
37
 
38
+ // `{ file }` entries are relative to the config file; the engine loads them by
39
+ // absolute path from the runtime's replacement-module endpoint.
40
+ function resolveFileEntries<T>(
41
+ entries: Record<string, T> | undefined,
42
+ configDir: string,
43
+ ): Record<string, T> | undefined {
44
+ if (!entries) return entries
45
+ return Object.fromEntries(
46
+ Object.entries(entries).map(([name, resolution]) => [
47
+ name,
48
+ resolution &&
49
+ typeof resolution === 'object' &&
50
+ 'file' in resolution &&
51
+ typeof resolution.file === 'string'
52
+ ? { ...resolution, file: path.resolve(configDir, resolution.file) }
53
+ : resolution,
54
+ ]),
55
+ )
56
+ }
57
+
37
58
  function resolveConfigReplacementFiles(
38
59
  config: PeachConfig,
39
60
  configPath: string,
40
61
  ): PeachConfig {
41
- if (!config.modules) return config
42
62
  const configDir = path.dirname(configPath)
43
63
  return {
44
64
  ...config,
45
- modules: Object.fromEntries(
46
- Object.entries(config.modules).map(([name, resolution]) => [
47
- name,
48
- resolution && typeof resolution === 'object' && 'file' in resolution
49
- ? { ...resolution, file: path.resolve(configDir, resolution.file) }
50
- : resolution,
51
- ]),
52
- ),
65
+ modules: resolveFileEntries(config.modules, configDir),
66
+ turboModules: resolveFileEntries(config.turboModules, configDir),
67
+ nativeModules: resolveFileEntries(config.nativeModules, configDir),
68
+ nativeViews: resolveFileEntries(config.nativeViews, configDir),
53
69
  }
54
70
  }
55
71
 
@@ -920,7 +920,6 @@ export class SootSimBridgeFlowRunner {
920
920
 
921
921
  private firstLaunchDone = false
922
922
  private appStopped = false
923
- private launchArgumentsActive = false
924
923
  private profilingEnabled = false
925
924
  private recordingEnabled = false
926
925
  private recordingAccessChecked = false
@@ -2604,22 +2603,6 @@ export class SootSimBridgeFlowRunner {
2604
2603
  throw new Error('extendedWaitUntil timed out')
2605
2604
  }
2606
2605
 
2607
- private async reloadGuestApp() {
2608
- // try the gentle reload first — in-place React re-mount preserves
2609
- // recording state and avoids the full worker lifecycle tear-down.
2610
- const reloaded = await this.bridge.send({
2611
- type: 'call',
2612
- simId: this.opts.simId,
2613
- path: 'SootSim.bridges.hotRemount.reloadExternalApp',
2614
- args: [],
2615
- })
2616
- if (reloaded) return
2617
-
2618
- // fallback for surfaces that don't export the hot-remount bridge: a
2619
- // hard page reload resets the tenant worker.
2620
- await this.evaluate('window.location.reload()').catch(() => {})
2621
- }
2622
-
2623
2606
  private async resetGuestAppData(
2624
2607
  launchOptions?: Pick<
2625
2608
  SootSimExternalAppLifecycleOptions,
@@ -2690,6 +2673,7 @@ export class SootSimBridgeFlowRunner {
2690
2673
  }
2691
2674
  const wantsClear = !!(opts && typeof opts === 'object' && opts.clearState)
2692
2675
  const wantsResetRuntime = readBooleanKey(opts, 'resetRuntime')
2676
+ const wantsForeground = isObjectRecord(opts) && opts.stopApp === false
2693
2677
  await this.setLaunchPermissions(isObjectRecord(opts) ? opts.permissions : undefined)
2694
2678
 
2695
2679
  if (!this.firstLaunchDone) {
@@ -2727,7 +2711,14 @@ export class SootSimBridgeFlowRunner {
2727
2711
  await this.waitForTree(120000)
2728
2712
  await this.waitForTree(30000)
2729
2713
  } else {
2730
- await this.launchShellAppFromHomeIfNeeded(opts)
2714
+ const launched = await this.launchShellAppFromHomeIfNeeded(opts)
2715
+ if (!launched && !wantsForeground) {
2716
+ await this.waitForTree(120000)
2717
+ await this.reloadGuestAppRuntime({
2718
+ launchArguments: null,
2719
+ initialUrl: null,
2720
+ })
2721
+ }
2731
2722
  await this.waitForTree(120000)
2732
2723
  await this.waitForTree(30000)
2733
2724
  }
@@ -2737,13 +2728,11 @@ export class SootSimBridgeFlowRunner {
2737
2728
  launchArguments: hasLaunchArguments ? launchArguments : null,
2738
2729
  initialUrl: null,
2739
2730
  })
2740
- } else if (hasLaunchArguments || this.launchArgumentsActive || this.appStopped) {
2731
+ } else if (hasLaunchArguments || this.appStopped || !wantsForeground) {
2741
2732
  await this.reloadGuestAppRuntime({
2742
2733
  launchArguments: hasLaunchArguments ? launchArguments : null,
2743
2734
  initialUrl: null,
2744
2735
  })
2745
- } else {
2746
- await this.reloadGuestApp()
2747
2736
  }
2748
2737
  await this.waitForTree(120000)
2749
2738
  if (this.opts.onAfterLaunch) {
@@ -2760,7 +2749,6 @@ export class SootSimBridgeFlowRunner {
2760
2749
  }
2761
2750
 
2762
2751
  this.appStopped = false
2763
- this.launchArgumentsActive = hasLaunchArguments
2764
2752
 
2765
2753
  if (this.profilingEnabled) {
2766
2754
  await this.startProfile()
@@ -3014,7 +3002,6 @@ export class SootSimBridgeFlowRunner {
3014
3002
  throw new Error(`openLink failed: ${result?.error || 'no test bridge'}`)
3015
3003
  }
3016
3004
  this.appStopped = false
3017
- this.launchArgumentsActive = false
3018
3005
  this.firstLaunchDone = true
3019
3006
  }
3020
3007
 
@@ -1,6 +1,15 @@
1
- // peach compat — scan dependencies with the shared compatibility engine.
1
+ // peach compat — scan dependencies with the shared compatibility engine, and
2
+ // the app's own native modules and views.
2
3
 
3
- import { relative, resolve } from 'node:path'
4
+ import {
5
+ existsSync,
6
+ lstatSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ realpathSync,
10
+ statSync,
11
+ } from 'node:fs'
12
+ import { dirname, join, relative, resolve } from 'node:path'
4
13
  import {
5
14
  COMPAT_RESULT_CAVEAT,
6
15
  formatDetailedScanLines,
@@ -8,8 +17,11 @@ import {
8
17
  scanDeps,
9
18
  type ScanResult,
10
19
  } from '@peach/compat/web'
20
+ import { rnxPublicBrand } from '../../src/public-brand'
21
+ import { resolveRNXAppConfig } from '../app-config'
11
22
  import {
12
23
  findProjectRoot,
24
+ scanRepository,
13
25
  scanWorkspace,
14
26
  installedManifest,
15
27
  readProjectManifest,
@@ -19,7 +31,7 @@ import type { ProjectManifest } from './projectReportContract'
19
31
  export async function runCompat(args: string[]): Promise<void> {
20
32
  if (args.includes('--help') || args.includes('-h')) {
21
33
  console.log(`
22
- peach compat — check package compatibility
34
+ peach compat — check package compatibility and the app's own native code
23
35
 
24
36
  usage:
25
37
  peach compat [package-name]
@@ -48,13 +60,13 @@ flags:
48
60
  }
49
61
 
50
62
  if (packageName) {
51
- report(args, scanOnePackage(appDir, pkg, packageName))
63
+ report(args, scanOnePackage(appDir, pkg, packageName), null)
52
64
  return
53
65
  }
54
66
 
55
67
  const here = scanDeps(installedManifest(appDir, pkg))
56
68
  if (here.packages.length > 0 || appIndex >= 0) {
57
- report(args, here)
69
+ report(args, here, await scanLocalNative(appDir, pkg))
58
70
  return
59
71
  }
60
72
 
@@ -67,13 +79,13 @@ flags:
67
79
  const manifest = readProjectManifest(app.dir)
68
80
  if (!manifest) return []
69
81
  const result = scanDeps(installedManifest(app.dir, manifest))
70
- return result.packages.length > 0 ? [{ dir: app.dir, result }] : []
82
+ return result.packages.length > 0 ? [{ dir: app.dir, manifest, result }] : []
71
83
  })
72
84
 
73
85
  if (elsewhere.length === 1) {
74
86
  const only = elsewhere[0]
75
87
  console.log(`\n scanning ${relative(appDir, only.dir) || '.'}`)
76
- report(args, only.result)
88
+ report(args, only.result, await scanLocalNative(resolve(only.dir), only.manifest))
77
89
  return
78
90
  }
79
91
  if (elsewhere.length > 1) {
@@ -84,16 +96,20 @@ flags:
84
96
  console.log()
85
97
  return
86
98
  }
87
- report(args, here)
99
+ report(args, here, await scanLocalNative(appDir, pkg))
88
100
  }
89
101
 
90
- function report(args: string[], result: ScanResult): void {
102
+ function report(args: string[], result: ScanResult, local: LocalNative[] | null): void {
91
103
  if (args.includes('--json')) {
92
- console.log(JSON.stringify(result, null, 2))
104
+ console.log(
105
+ JSON.stringify(local ? { ...result, localNative: local } : result, null, 2),
106
+ )
93
107
  return
94
108
  }
95
109
  if (args.includes('--brief')) {
96
110
  console.log(` ${formatScanCounts(result)}`)
111
+ if (local?.length)
112
+ console.log(` the app's own native modules and views: ${local.length}`)
97
113
  console.log(` ${COMPAT_RESULT_CAVEAT}`)
98
114
  return
99
115
  }
@@ -110,9 +126,217 @@ function report(args: string[], result: ScanResult): void {
110
126
  if (result.packages.length === 0) {
111
127
  console.log(` no React Native ecosystem packages detected`)
112
128
  }
129
+ if (local?.length) reportLocalNative(local)
113
130
  console.log(`\n ${COMPAT_RESULT_CAVEAT}\n`)
114
131
  }
115
132
 
133
+ function reportLocalNative(local: LocalNative[]): void {
134
+ console.log(`\n the app's own native modules and views`)
135
+ console.log(` Peach does not run an app's native code. until peach.config.ts answers`)
136
+ console.log(` for one, a module is an inert stand-in and a view renders empty.\n`)
137
+ const open = local.filter((item) => !item.answered)
138
+ const width = Math.max(...local.map((item) => item.name.length))
139
+ for (const item of local) {
140
+ const where = item.expoModule
141
+ ? `${item.path} (Expo module ${item.expoModule}${item.swift ? ', Swift' : ''})`
142
+ : item.path
143
+ console.log(
144
+ ` ${item.kind === 'view' ? 'view ' : 'module'} ${item.name.padEnd(width)} ${where}${item.answered ? ' answered in peach.config.ts' : ''}`,
145
+ )
146
+ }
147
+ const modules = open.filter((item) => item.kind === 'module')
148
+ const views = open.filter((item) => item.kind === 'view')
149
+ if (open.length) {
150
+ console.log(
151
+ `\n answer ${open.length === local.length ? 'them' : 'the rest'} from peach.config.ts:\n`,
152
+ )
153
+ console.log(` export default defineConfig({`)
154
+ if (modules.length) {
155
+ console.log(` nativeModules: {`)
156
+ for (const item of modules) {
157
+ console.log(` ${item.name}: { file: './peach/${item.name}.ts' },`)
158
+ }
159
+ console.log(` },`)
160
+ }
161
+ if (views.length) {
162
+ console.log(` nativeViews: {`)
163
+ for (const item of views) {
164
+ console.log(` ${item.name}: { file: './peach/${item.name}.tsx' },`)
165
+ }
166
+ console.log(` },`)
167
+ }
168
+ console.log(` })`)
169
+ }
170
+ console.log(`\n guide: ${rnxPublicBrand.origin}/docs/rnx/compat/custom-stubs`)
171
+ }
172
+
173
+ // an app's own native code, found from the names its JavaScript asks the native
174
+ // side for and from local Expo modules (a directory with expo-module.config.json).
175
+ // each name is the key the matching peach.config slot takes.
176
+ export interface LocalNative {
177
+ kind: 'module' | 'view'
178
+ name: string
179
+ // first place the name appears, relative to the app, as file:line
180
+ path: string
181
+ // the local Expo module directory the name belongs to, relative to the app
182
+ expoModule: string | null
183
+ swift: boolean
184
+ // the app's peach.config.ts already names it
185
+ answered: boolean
186
+ }
187
+
188
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/
189
+ const NATIVE_SOURCE_FILE = /\.(?:swift|kt)$/
190
+ // a requireNativeView(module, view) name is `module_view`, the key the engine
191
+ // registers expo views under.
192
+ const LOOKUPS: { kind: LocalNative['kind']; pattern: RegExp }[] = [
193
+ {
194
+ kind: 'view',
195
+ pattern: /\brequireNativeComponent\s*(?:<[^;]{0,200}?>)?\(\s*['"]([\w$.-]+)['"]/g,
196
+ },
197
+ {
198
+ kind: 'view',
199
+ pattern:
200
+ /\brequireNativeView(?:Manager)?\s*(?:<[^;]{0,200}?>)?\(\s*['"](\w+)['"]\s*(?:,\s*['"](\w+)['"])?/g,
201
+ },
202
+ {
203
+ kind: 'module',
204
+ pattern:
205
+ /\brequire(?:Optional)?NativeModule\s*(?:<[^;]{0,200}?>)?\(\s*['"](\w+)['"]/g,
206
+ },
207
+ {
208
+ kind: 'module',
209
+ pattern:
210
+ /\bTurboModuleRegistry\.get(?:Enforcing)?\s*(?:<[^;]{0,200}?>)?\(\s*['"](\w+)['"]/g,
211
+ },
212
+ ]
213
+
214
+ async function scanLocalNative(
215
+ appDir: string,
216
+ pkg: ProjectManifest,
217
+ ): Promise<LocalNative[]> {
218
+ const { config } = await resolveRNXAppConfig(appDir)
219
+ const answered = {
220
+ module: new Set(Object.keys({ ...config?.turboModules, ...config?.nativeModules })),
221
+ view: new Set(Object.keys(config?.nativeViews ?? {})),
222
+ }
223
+ const expoModuleDirs: string[] = []
224
+ const sourceFiles: string[] = []
225
+ for (const root of [appDir, ...workspacePackageDirs(appDir, pkg)]) {
226
+ for (const dir of scanRepository(root).directories) {
227
+ if (existsSync(join(dir, 'expo-module.config.json'))) expoModuleDirs.push(dir)
228
+ for (const name of listDir(dir)) {
229
+ if (SOURCE_FILE.test(name) && !name.endsWith('.d.ts'))
230
+ sourceFiles.push(join(dir, name))
231
+ }
232
+ }
233
+ }
234
+ const nativeFilesByModule = new Map(
235
+ expoModuleDirs.map((dir) => [
236
+ dir,
237
+ scanRepository(dir).directories.flatMap((sub) =>
238
+ listDir(sub)
239
+ .filter((name) => NATIVE_SOURCE_FILE.test(name))
240
+ .map((name) => join(sub, name)),
241
+ ),
242
+ ]),
243
+ )
244
+
245
+ const found = new Map<string, LocalNative>()
246
+ const add = (
247
+ kind: LocalNative['kind'],
248
+ name: string,
249
+ file: string,
250
+ text: string,
251
+ at = 0,
252
+ ) => {
253
+ const key = `${kind}:${name}`
254
+ if (found.has(key)) return
255
+ const moduleDir = expoModuleDirs.find((dir) => file.startsWith(`${dir}/`)) ?? null
256
+ found.set(key, {
257
+ kind,
258
+ name,
259
+ path: `${relative(appDir, file)}:${text.slice(0, at).split('\n').length}`,
260
+ expoModule: moduleDir ? relative(appDir, moduleDir) || '.' : null,
261
+ swift: moduleDir
262
+ ? (nativeFilesByModule.get(moduleDir) ?? []).some((path) =>
263
+ path.endsWith('.swift'),
264
+ )
265
+ : false,
266
+ answered: answered[kind].has(name),
267
+ })
268
+ }
269
+ for (const file of sourceFiles) {
270
+ const text = readSmallFile(file)
271
+ if (!text || !/requireNative|TurboModuleRegistry/.test(text)) continue
272
+ for (const { kind, pattern } of LOOKUPS) {
273
+ for (const match of text.matchAll(pattern)) {
274
+ const name = match[2] ? `${match[1]}_${match[2]}` : match[1]
275
+ if (name) add(kind, name, file, text, match.index)
276
+ }
277
+ }
278
+ }
279
+ // a local expo module declares its name in its native definition, Name("X"),
280
+ // whether or not the app's javascript spells it out.
281
+ for (const [, files] of nativeFilesByModule) {
282
+ for (const file of files) {
283
+ const text = readSmallFile(file)
284
+ if (!text) continue
285
+ for (const match of text.matchAll(/\bName\(\s*"(\w+)"\s*\)/g)) {
286
+ if (match[1]) add('module', match[1], file, text, match.index)
287
+ }
288
+ }
289
+ }
290
+ return [...found.values()].sort(
291
+ (a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name),
292
+ )
293
+ }
294
+
295
+ function listDir(dir: string): string[] {
296
+ try {
297
+ return readdirSync(dir)
298
+ } catch {
299
+ return []
300
+ }
301
+ }
302
+
303
+ function readSmallFile(file: string): string | null {
304
+ try {
305
+ return statSync(file).size <= 1024 * 1024 ? readFileSync(file, 'utf8') : null
306
+ } catch {
307
+ return null
308
+ }
309
+ }
310
+
311
+ // dependencies installed as links to source outside any node_modules are the
312
+ // app's own workspace packages, which hold its native code as often as the app
313
+ // directory does.
314
+ function workspacePackageDirs(appDir: string, pkg: ProjectManifest): string[] {
315
+ const dirs = new Set<string>()
316
+ for (const name of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })) {
317
+ let current = appDir
318
+ while (true) {
319
+ const link = join(current, 'node_modules', name)
320
+ if (existsSync(link)) {
321
+ if (lstatSync(link).isSymbolicLink()) {
322
+ const target = realpathSync(link)
323
+ if (
324
+ !target.split('/').includes('node_modules') &&
325
+ !target.startsWith(`${appDir}/`)
326
+ ) {
327
+ dirs.add(target)
328
+ }
329
+ }
330
+ break
331
+ }
332
+ const parent = dirname(current)
333
+ if (parent === current) break
334
+ current = parent
335
+ }
336
+ }
337
+ return [...dirs]
338
+ }
339
+
116
340
  function scanOnePackage(
117
341
  appDir: string,
118
342
  pkg: ProjectManifest,
@@ -313,7 +313,7 @@ export async function runDoChain(
313
313
  const { SimPerformError } = simClientModule
314
314
  const { checkSimHealth, parseBridgeCliArgs } = bridgeModule
315
315
  const { isAgentEnv } = envModule
316
- const { resolveTargetCoords } = targetModule
316
+ const { offscreenTargetRefusal, resolveTargetCoords } = targetModule
317
317
  const wantsJson = args.includes('--json')
318
318
  const skipSettle = args.includes('--no-wait') || process.env.PEACH_NO_AUTO_WAIT === '1'
319
319
  const parsed = parseBridgeCliArgs(args, {
@@ -355,6 +355,10 @@ export async function runDoChain(
355
355
  `do chain could not find testID ${JSON.stringify(action.id)}`,
356
356
  )
357
357
  }
358
+ const refusal = offscreenTargetRefusal(sim.plane, target)
359
+ if (refusal) {
360
+ throw new BridgeInputError(`do chain #${action.id}: ${refusal}`)
361
+ }
358
362
  steps.push(
359
363
  action.type === 'targetedDoubleTap'
360
364
  ? {
@@ -1,5 +1,11 @@
1
1
  import { inspectTree, type InspectBridge } from './core'
2
- import { resolveTargetCoords, type ResolvedTargetCoords } from './resolve-target'
2
+ import {
3
+ isOffscreen,
4
+ isScreenSize,
5
+ resolveTargetCoords,
6
+ UNREPORTED_SCREEN_ERROR,
7
+ type ResolvedTargetCoords,
8
+ } from './resolve-target'
3
9
  import { waitForSootsimIdle } from './settling'
4
10
  import type { SimSemanticNode } from '../../../src/bridge-contract'
5
11
  import type {
@@ -39,54 +45,6 @@ export interface TapTimingOptions {
39
45
  retryWaitMs?: number
40
46
  }
41
47
 
42
- type ScreenSize = { width: number; height: number }
43
-
44
- function isScreenSize(value: unknown): value is ScreenSize {
45
- if (value === null || typeof value !== 'object') return false
46
- const width = Reflect.get(value, 'width')
47
- const height = Reflect.get(value, 'height')
48
- return (
49
- typeof width === 'number' &&
50
- Number.isFinite(width) &&
51
- width > 0 &&
52
- typeof height === 'number' &&
53
- Number.isFinite(height) &&
54
- height > 0
55
- )
56
- }
57
-
58
- function viewportFromCapture(value: unknown): unknown {
59
- if (value === null || typeof value !== 'object') return undefined
60
- const tree = Reflect.get(value, 'tree')
61
- return tree !== null && typeof tree === 'object'
62
- ? Reflect.get(tree, 'viewport')
63
- : undefined
64
- }
65
-
66
- // the semantic tree has no covering root, so its first node is often a 64x64
67
- // icon. capture.tree.viewport is the device layout size on local and cloud.
68
- //
69
- // a whole screen capture is the only way to ask for it today, and it is not
70
- // cheap: the headless runtime forces a full redraw of the tree before it walks
71
- // it, which measured 3.9-5.5s on a real app in a cloud box against 120-290ms
72
- // for every other command. that is why this runs on the failure path only.
73
- async function readScreen(bridge: InspectBridge): Promise<ScreenSize | null> {
74
- try {
75
- const viewport = viewportFromCapture(
76
- await bridge.send({ type: 'capture', includeVisual: false }),
77
- )
78
- return isScreenSize(viewport)
79
- ? { width: viewport.width, height: viewport.height }
80
- : null
81
- } catch {
82
- return null
83
- }
84
- }
85
-
86
- function isOffscreen(cx: number, cy: number, screen: ScreenSize): boolean {
87
- return !(cx >= 0 && cy >= 0 && cx <= screen.width && cy <= screen.height)
88
- }
89
-
90
48
  const DEFAULT_AGENT_TIMING: Required<TapTimingOptions> = {
91
49
  initialWaitMs: 3000,
92
50
  deadlineMs: 5000,
@@ -185,19 +143,24 @@ export async function tapResolvedTarget(
185
143
 
186
144
  const deadline = Date.now() + timing.deadlineMs
187
145
  let lastOffscreenAt: string | null = null
188
- // a cloud sim refuses a point outside its screen itself, and says so, so
189
- // asking it for a capture first would buy nothing. every other plane still
190
- // presses whatever the hit test finds at tree coordinates no finger can
191
- // reach, so there the screen has to be read before dispatching.
192
- const screen = bridge.plane === 'cloud' ? null : await readScreen(bridge)
193
146
  while (Date.now() <= deadline || attempts === 0) {
194
147
  attempts++
195
- const resolved = await args.resolve()
148
+ const { screen, ...resolved } = (await args.resolve()) ?? {}
149
+ // a cloud sim refuses a point outside its screen itself, and says so. every
150
+ // other plane still presses whatever the hit test finds at tree
151
+ // coordinates no finger can reach, so the resolved target's screen decides
152
+ // before dispatching. a runtime too old to report it cannot be checked; a
153
+ // zero screen is one not laid out yet, so the attempt waits and retries.
154
+ const checksScreen = bridge.plane !== 'cloud' && typeof resolved.cx === 'number'
155
+ if (checksScreen && screen === undefined) {
156
+ throw new Error(UNREPORTED_SCREEN_ERROR)
157
+ }
158
+ const unsized = checksScreen && !isScreenSize(screen)
196
159
  const payload =
197
- resolved &&
160
+ checksScreen &&
198
161
  typeof resolved.cx === 'number' &&
199
162
  typeof resolved.cy === 'number' &&
200
- screen &&
163
+ isScreenSize(screen) &&
201
164
  isOffscreen(resolved.cx, resolved.cy, screen)
202
165
  ? { ...resolved, offscreen: true, screen }
203
166
  : resolved
@@ -215,7 +178,10 @@ export async function tapResolvedTarget(
215
178
  // don't dispatch a point outside the device screen: the press would do
216
179
  // nothing a user could reproduce. report the offscreen miss instead so
217
180
  // drivers scroll first rather than getting stuck trusting a phantom hit.
218
- if (payload.offscreen) {
181
+ if (unsized) {
182
+ lastResult = { hit: false, reason: 'screen-not-laid-out' }
183
+ lastOffscreenAt = null
184
+ } else if (payload.offscreen) {
219
185
  lastResult = {
220
186
  hit: false,
221
187
  reason: 'offscreen',
@@ -310,6 +276,7 @@ function payloadFromResolved(
310
276
  return {
311
277
  cx: resolved.x,
312
278
  cy: resolved.y,
279
+ screen: resolved.viewport,
313
280
  match: {
314
281
  nodeId: match.nodeId ?? null,
315
282
  id: match.testID ?? null,
@@ -489,25 +456,14 @@ async function resolveTextTapTarget(
489
456
  if (!picked) return { matched: 0, total }
490
457
  const node = picked.node
491
458
  // 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],
459
+ // different control, including a covered tenant behind native UI. resolve by
460
+ // node is a read, so a remote simulator's driving token can send it.
461
+ const resolved = await resolveTargetCoords(bridge, {
462
+ mode: 'node',
463
+ nodeId: node.nodeId,
497
464
  })
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
- )
465
+ if (!resolved) return { matched: 0, total }
466
+ return payloadFromResolved(resolved, node, { total, idx })
511
467
  }
512
468
 
513
469
  export type TapQuery =
@@ -567,6 +523,7 @@ export async function tapByQuery(
567
523
  testID: tap.target.testID,
568
524
  text: tap.target.text ?? tap.target.accessibilityLabel,
569
525
  type: tap.target.type,
526
+ viewport: tap.viewport,
570
527
  },
571
528
  {
572
529
  nodeId: node.nodeId,