peachsim 0.1.1625 → 0.1.1635

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 (152) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +10 -1
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +64 -43
  7. package/cli/command-registry.ts +1 -0
  8. package/cli/commands/control.ts +283 -49
  9. package/cli/commands/doctor.ts +17 -184
  10. package/cli/commands/flow.ts +79 -11
  11. package/cli/commands/inspect/core.ts +10 -5
  12. package/cli/commands/inspect/settling.ts +14 -2
  13. package/cli/commands/inspect/wait-ready.ts +12 -0
  14. package/cli/commands/inspect.ts +26 -3
  15. package/cli/commands/install-desktop.ts +1 -0
  16. package/cli/commands/maestro-compare.ts +97 -38
  17. package/cli/commands/maestro.ts +9 -1
  18. package/cli/commands/record.ts +21 -1
  19. package/cli/commands/screenshot.ts +23 -1
  20. package/cli/commands/serve.ts +7 -2
  21. package/cli/commands/setup.ts +19 -42
  22. package/cli/commands/swift.ts +5 -0
  23. package/cli/commands/upload.ts +46 -7
  24. package/cli/desktop-companion.ts +102 -17
  25. package/cli/drivers/desktop.ts +14 -1
  26. package/cli/drivers/playwright-sim-host.ts +38 -3
  27. package/cli/drivers/playwright.ts +9 -8
  28. package/cli/drivers/types.ts +5 -0
  29. package/cli/maestro-accessibility.ts +1 -1
  30. package/cli/maestro-compare.ts +84 -12
  31. package/cli/main.ts +2 -1
  32. package/cli/outbound-endpoints.ts +13 -1
  33. package/cli/pause-aware-timeout.ts +102 -0
  34. package/cli/scene-video.ts +89 -0
  35. package/cli/ws-bridge.ts +23 -14
  36. package/detox/attempt-events.cjs +4 -0
  37. package/detox/index.ts +167 -142
  38. package/detox/motion-change.cjs +57 -21
  39. package/detox/motion-change.d.cts +7 -2
  40. package/dist-lib/agent-daemon-client.cjs +1 -1
  41. package/dist-lib/agent-events.cjs +1 -1
  42. package/dist-lib/attached-projects.cjs +1 -1
  43. package/dist-lib/auth/shared-session.cjs +1 -1
  44. package/dist-lib/backend-origin.cjs +1 -1
  45. package/dist-lib/beta.cjs +1 -1
  46. package/dist-lib/beta.mjs +1 -1
  47. package/dist-lib/bridge-constants.cjs +13 -1
  48. package/dist-lib/bridge-contract-input.cjs +1 -1
  49. package/dist-lib/bridge-contract-input.mjs +1 -1
  50. package/dist-lib/bridge-contract.cjs +14 -8
  51. package/dist-lib/bridge-contract.mjs +13 -7
  52. package/dist-lib/bundle-channel.cjs +9536 -7604
  53. package/dist-lib/capture-contract.cjs +1 -1
  54. package/dist-lib/capture-contract.mjs +1 -1
  55. package/dist-lib/cloud-contract.cjs +1 -1
  56. package/dist-lib/cloud-contract.mjs +1 -1
  57. package/dist-lib/cloud-test-args.cjs +1 -1
  58. package/dist-lib/cloud-test-args.mjs +1 -1
  59. package/dist-lib/cloud.cjs +1 -1
  60. package/dist-lib/cloud.mjs +1 -1
  61. package/dist-lib/config.cjs +6 -3
  62. package/dist-lib/config.mjs +6 -3
  63. package/dist-lib/detox/index.cjs +895 -664
  64. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  65. package/dist-lib/expo-swift-bundle.cjs +7818 -5916
  66. package/dist-lib/expo-swift-metadata.cjs +3 -1
  67. package/dist-lib/expo-swift-metadata.mjs +3 -1
  68. package/dist-lib/home-paths.cjs +32 -3
  69. package/dist-lib/host/bridge-host.cjs +8340 -6392
  70. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  71. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  72. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  73. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  74. package/dist-lib/host/websocket-proxy.cjs +1 -1
  75. package/dist-lib/index.cjs +812 -339
  76. package/dist-lib/jump-to-source-babel.cjs +1 -1
  77. package/dist-lib/jump-to-source-native.cjs +1 -1
  78. package/dist-lib/kotlin-host.cjs +1 -1
  79. package/dist-lib/menu.cjs +1 -1
  80. package/dist-lib/menu.mjs +1 -1
  81. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  82. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  83. package/dist-lib/metro-production-bundle.cjs +1 -1
  84. package/dist-lib/metro-production-bundle.mjs +1 -1
  85. package/dist-lib/metro.cjs +391 -28
  86. package/dist-lib/native-app-identity-project.cjs +55 -2
  87. package/dist-lib/native-app-identity-project.mjs +55 -2
  88. package/dist-lib/native-app-identity.cjs +53 -1
  89. package/dist-lib/native-app-identity.mjs +52 -1
  90. package/dist-lib/native-firebase-metadata.cjs +1 -1
  91. package/dist-lib/native-firebase-metadata.mjs +1 -1
  92. package/dist-lib/plist.cjs +1 -1
  93. package/dist-lib/plist.mjs +1 -1
  94. package/dist-lib/profiles.cjs +1 -1
  95. package/dist-lib/public-brand.cjs +1 -1
  96. package/dist-lib/react-native-host-modules.cjs +5 -1
  97. package/dist-lib/react-native-host-modules.mjs +5 -1
  98. package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
  99. package/dist-lib/sdk.cjs +27 -11
  100. package/dist-lib/sdk.mjs +27 -10
  101. package/dist-lib/skills.cjs +12276 -8947
  102. package/dist-lib/swift-compile.cjs +924 -118
  103. package/dist-lib/swift-compile.mjs +966 -152
  104. package/dist-lib/swift-package.cjs +12 -3
  105. package/dist-lib/swift-package.mjs +12 -3
  106. package/dist-lib/swift-submit.cjs +7 -1
  107. package/dist-lib/swift-submit.mjs +5 -1
  108. package/dist-lib/swift-toolchain.cjs +133 -18
  109. package/dist-lib/swift.cjs +1 -1
  110. package/dist-lib/vite-swift.cjs +1955 -498
  111. package/dist-lib/vite.cjs +3 -1
  112. package/dist-lib/worklet-executable.cjs +1 -1
  113. package/dist-lib/worklet-executable.mjs +1 -1
  114. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  115. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  116. package/package.json +1 -1
  117. package/scripts/dev-server-scanner.ts +2 -1
  118. package/skills/peach-perf/SKILL.md +11 -5
  119. package/skills/peach-setup/SKILL.md +16 -19
  120. package/src/bridge-constants.ts +11 -0
  121. package/src/bridge-contract.ts +22 -34
  122. package/src/config.ts +15 -0
  123. package/src/desktop-host-delivery.ts +5 -1
  124. package/src/dev-bundle-resolution.ts +2 -1
  125. package/src/expo-manifest-body.ts +13 -0
  126. package/src/expo-swift-host.ts +191 -47
  127. package/src/expo-swift-ready.ts +78 -0
  128. package/src/expoSwiftContract.ts +13 -0
  129. package/src/home-paths.ts +48 -2
  130. package/src/host/bridge-host.ts +5 -2
  131. package/src/native-app-identity-project.ts +26 -2
  132. package/src/native-app-identity.ts +90 -0
  133. package/src/native-firebase-config.ts +209 -13
  134. package/src/process-group.ts +25 -2
  135. package/src/react-native-bridge-modules.ts +416 -0
  136. package/src/react-native-host-modules.ts +4 -0
  137. package/src/swift-binary-modules.ts +2 -2
  138. package/src/swift-compile.ts +835 -84
  139. package/src/swift-compiler-digest.ts +14 -0
  140. package/src/swift-distribution.lock.json +14 -12
  141. package/src/swift-host.ts +91 -29
  142. package/src/swift-icu.ts +14 -6
  143. package/src/swift-objc-sdk.ts +133 -10
  144. package/src/swift-objc-standin.ts +25 -3
  145. package/src/swift-package-manifest.ts +21 -6
  146. package/src/swift-package.ts +15 -1
  147. package/src/swift-packages.ts +119 -60
  148. package/src/swift-standin-emit.ts +57 -9
  149. package/src/swift-standins.ts +168 -20
  150. package/src/swift-submit.ts +10 -0
  151. package/src/swift-toolchain.ts +149 -23
  152. package/src/vite-plugin.ts +12 -1
package/LICENSE CHANGED
@@ -8,7 +8,7 @@ the Peach Software License Agreement: https://peachsim.com/legal/license
8
8
  In summary (the agreement governs): Seed is free for individuals and companies
9
9
  under 10 people and under $1M yearly revenue, including commercial desktop and
10
10
  local use. A company at or above either limit needs Team while it has under
11
- 100 people and under $10M yearly revenue, or Enterprise at or above either of
11
+ 25 people and under $3M yearly revenue, or Enterprise at or above either of
12
12
  those limits. Members, agents, API keys and local simulators are unlimited.
13
13
  Company size is self-reported and enforced through the license terms only.
14
14
  Embedding or redistributing the software requires a separate written agreement
package/README.md CHANGED
@@ -323,7 +323,16 @@ resolve` re-pins it, and the CLI release publishes any bundle the bucket lacks.
323
323
  On Mac, headless launches attach an installed compatible native host. With no
324
324
  host, they render in the browser without downloading or placing the app. The
325
325
  browser logo menu offers the app; `peach desktop install --native` remains the
326
- explicit CLI installer. Linux still acquires its standalone host under `~/.peach`.
326
+ explicit CLI installer. Linux x64 headless opens acquire the native standalone
327
+ host under `~/.peach` by default. To choose plain Chromium for an open, use
328
+ `peach open <target> --driver playwright --headless --chromium`.
329
+ Pin the native host for reproducible Linux runs with
330
+ `PEACH_DESKTOP_VERSION=<build>.<attempt> peach open <target> --headless`.
331
+ A cached pin is used directly, even if another build is active; a missing pin
332
+ is downloaded exactly, and an unavailable or incompatible pin fails the open.
333
+ `peach list --drivers` shows installed hosts and each running sim's renderer
334
+ and native executable path. Renderer choices select a matching sim or launch
335
+ one; they never change a running sim's renderer.
327
336
 
328
337
  Headed launches on Linux use full Chromium, whose extra libraries the bundle
329
338
  also carries. Later opens reuse that sim and
package/cli/app-config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs'
1
2
  import path from 'node:path'
2
3
  import { createJiti } from 'jiti'
3
4
  import * as peachConfigModule from '../src/config'
@@ -80,7 +81,9 @@ async function loadConfigFile(
80
81
  [`${rnxPublicBrand.packageName}/config`]: peachConfigModule,
81
82
  },
82
83
  })
83
- const loaded: unknown = await jiti.import(project.configPath, { default: true })
84
+ const loaded: unknown = project.configPath.endsWith('.json')
85
+ ? JSON.parse(readFileSync(project.configPath, 'utf8'))
86
+ : await jiti.import(project.configPath, { default: true })
84
87
  if (!isPeachConfig(loaded)) {
85
88
  throw new Error('the default export must be a peach config object')
86
89
  }
package/cli/app-fonts.ts CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  resolveAppFontsWire,
8
8
  expoFontPathsFromPlugins,
9
9
  nativeLinkedFontSpecsFromPaths,
10
- metroFontSpecsFromPaths,
11
10
  appFontAssetUrlsFromWire,
12
11
  type ConfigPluginFontSpec,
13
12
  type FontPlatform,
@@ -15,8 +14,10 @@ import {
15
14
  import { resolveListeningPid, resolveProcessCwd } from '../scripts/dev-server-scanner'
16
15
  import { loopbackOriginOf } from '../src/backend-origin'
17
16
  import { MANIFEST_PROBE_TIMEOUT_MS } from '../src/dev-bundle-resolution'
17
+ import { parseExpoManifestBody } from '../src/expo-manifest-body'
18
18
  import { stageHostFile } from '../src/host/staged-host-files'
19
19
  import { parsePlistSource } from '../src/plist.ts'
20
+ import { listXcodeAppTargets, readXcodeAppTarget } from '../src/xcode-project'
20
21
  import { resolveAppProject } from './app-project'
21
22
  import {
22
23
  materialSymbolFontPaths,
@@ -31,24 +32,22 @@ export type DiscoveredAppFontSpec = ConfigPluginFontSpec
31
32
 
32
33
  type ExpoConfigGetter = (dir: string, opts: Record<string, unknown>) => { exp?: unknown }
33
34
 
34
- // every font file is found on disk, so the asset urls are decided here and
35
- // never by asking the dev server: metro's asset endpoint resolves a path
36
- // against its config.projectRoot, which is the root it advertises and the one
37
- // projectDir names. a reachability request would make the result depend on how
38
- // busy the dev server is, and a cold build answers late enough to drop fonts.
35
+ // every font file is found on disk, so its bytes are staged as host files and
36
+ // never fetched from the dev server: only metro serves project files at
37
+ // `/assets/<path>`, and a reachability request would make the result depend on
38
+ // how busy the dev server is, so a cold build could answer late and drop fonts.
39
39
  export function discoverNativeLinkedAppFonts(opts: {
40
- bundleUrl: string
41
40
  projectDir: string
42
41
  platform?: string
43
42
  nativeAssetOrigin: string
44
43
  }): DiscoveredAppFontSpec[] {
45
- const metroOrigin = loopbackOriginOf(opts.bundleUrl)
46
- if (!metroOrigin) return []
47
44
  const { paths, materialSymbols } = discoverNativeLinkedAppFontAssets(opts)
48
- const specs = metroFontSpecsFromPaths(
45
+ const realProjectRoot = fs.realpathSync(opts.projectDir)
46
+ const specs = nativeLinkedFontSpecsFromPaths(
49
47
  projectRelativeFontPaths(opts.projectDir, paths),
50
- metroOrigin,
51
48
  nativeFontPlatform(opts.platform),
49
+ (fontPath) =>
50
+ stageHostFile(path.resolve(realProjectRoot, fontPath), opts.nativeAssetOrigin),
52
51
  )
53
52
  attachMaterialSymbolMetadata(specs, materialSymbols, (asset) =>
54
53
  stageHostFile(asset, opts.nativeAssetOrigin),
@@ -286,6 +285,23 @@ function discoverNativeLinkedAppFontAssets(opts: {
286
285
  !entry.name.startsWith('.'),
287
286
  )
288
287
  .slice(0, 64)
288
+ // a bare filename in UIAppFonts names a file the target copies into
289
+ // its bundle, wherever the project keeps it: resolve it by the target's
290
+ // resource build phase before trying paths beside the plist.
291
+ const bundledByName = new Map<string, string>()
292
+ for (const entry of fs.readdirSync(iosDir, { withFileTypes: true })) {
293
+ if (!entry.isDirectory() || !entry.name.endsWith('.xcodeproj')) continue
294
+ const xcodeproj = path.join(iosDir, entry.name)
295
+ for (const target of listXcodeAppTargets(xcodeproj, { requireSources: false })) {
296
+ const members = readXcodeAppTarget(xcodeproj, target, undefined, {
297
+ requireSources: false,
298
+ })
299
+ for (const file of members.resources) {
300
+ if (!bundledByName.has(path.basename(file)))
301
+ bundledByName.set(path.basename(file), file)
302
+ }
303
+ }
304
+ }
289
305
  const plistPaths = [path.join(iosDir, 'Info.plist')]
290
306
  for (const entry of targetDirs) {
291
307
  plistPaths.push(path.join(iosDir, entry.name, 'Info.plist'))
@@ -302,15 +318,19 @@ function discoverNativeLinkedAppFontAssets(opts: {
302
318
  if (!Array.isArray(declaredFonts)) continue
303
319
  for (const declaredFont of declaredFonts) {
304
320
  if (typeof declaredFont !== 'string') continue
321
+ // a file the target copies into its bundle is linked wherever it
322
+ // lives, including a workspace package's folder outside the app.
323
+ const bundled = bundledByName.get(declaredFont)
305
324
  const candidates = [
306
325
  path.resolve(path.dirname(plistPath), declaredFont),
307
326
  path.resolve(iosDir, declaredFont),
308
327
  ]
309
- const fontPath = candidates.find((candidate) => {
310
- const relative = path.relative(selectedAppDir, candidate)
311
- if (relative.startsWith('..') || path.isAbsolute(relative)) return false
312
- return isFile(candidate)
313
- })
328
+ const fontPath =
329
+ candidates.find((candidate) => {
330
+ const relative = path.relative(selectedAppDir, candidate)
331
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return false
332
+ return isFile(candidate)
333
+ }) ?? (bundled && isFile(bundled) ? bundled : undefined)
314
334
  if (fontPath) fontPaths.push(fontPath)
315
335
  }
316
336
  }
@@ -383,7 +403,7 @@ export async function resolveMetroProjectRoot(bundleUrl: string): Promise<string
383
403
  signal: controller.signal,
384
404
  })
385
405
  if (res.ok) {
386
- const manifest = (await res.json()) as {
406
+ const manifest = parseExpoManifestBody(await res.text()) as {
387
407
  extra?: { expoClient?: { _internal?: { projectRoot?: unknown } } }
388
408
  }
389
409
  servedManifest = true
package/cli/app-stubs.ts CHANGED
@@ -3,7 +3,14 @@
3
3
  // packages, and a library's own `peach` field, lowered into the engine's
4
4
  // nativeModules / nativeViews wire slots.
5
5
 
6
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
6
+ import {
7
+ existsSync,
8
+ lstatSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ statSync,
13
+ } from 'node:fs'
7
14
  import { dirname, join, relative, resolve } from 'node:path'
8
15
  import { resolveCompatVersionRange } from '@peach/compat/version-range'
9
16
  import { parseSync, type Expression } from 'oxc-parser'
@@ -19,7 +26,14 @@ import {
19
26
  } from '../src/config'
20
27
  import { isRecord } from '../src/is-record'
21
28
  import { visitParsedProgram } from '../src/visit-parsed-program'
22
- import { readProjectManifest, scanRepository } from './commands/detect'
29
+ import {
30
+ readProjectManifest,
31
+ scanRepository,
32
+ metadataValue,
33
+ readProjectJson,
34
+ } from './commands/detect'
35
+ import type { ProjectManifest } from './commands/projectReportContract'
36
+ import type { ScanResult } from '@peach/compat/web'
23
37
 
24
38
  // who answers a package, in rising precedence
25
39
  export type StubOrigin = 'library' | 'stub-package' | 'config'
@@ -125,7 +139,25 @@ export function resolveAppStubs(
125
139
  baseDir: dir,
126
140
  })
127
141
  }
128
- for (const [target, stubs] of Object.entries(config?.packages ?? {})) {
142
+ const configuredPackages = Object.entries(config?.packages ?? {})
143
+ const packageNames = new Set<string>()
144
+ if (typeof manifest?.name === 'string') packageNames.add(manifest.name)
145
+ for (const [target] of configuredPackages) {
146
+ const dir = installedPackageDir(appDir, target)
147
+ if (dir && readPackageJson(dir)?.name === target) packageNames.add(target)
148
+ }
149
+ if (manifest && configuredPackages.some(([target]) => !packageNames.has(target))) {
150
+ for (const root of [appDir, ...workspacePackageDirs(appDir, manifest)])
151
+ for (const dir of scanRepository(root).directories) {
152
+ const name = readPackageJson(dir)?.name
153
+ if (typeof name === 'string') packageNames.add(name)
154
+ }
155
+ }
156
+ for (const [target, stubs] of configuredPackages) {
157
+ if (manifest && !packageNames.has(target))
158
+ throw new Error(
159
+ `packages[${JSON.stringify(target)}]: no installed or local package has this name; use the name from the package's package.json`,
160
+ )
129
161
  sources.push({
130
162
  target,
131
163
  origin: 'config',
@@ -368,12 +400,17 @@ export function wireSlotMigration(
368
400
  if (!owners.has(`${request.kind}:${request.name}`))
369
401
  owners.set(`${request.kind}:${request.name}`, name)
370
402
  }
371
- // the app's own native code answers under its package name
372
- if (typeof manifest?.name === 'string')
373
- for (const request of packageNativeRequests(appDir))
403
+ if (manifest)
404
+ for (const request of scanLocalNative(
405
+ appDir,
406
+ manifest,
407
+ { module: new Set(), view: new Set() },
408
+ undefined,
409
+ new Set(),
410
+ true,
411
+ ))
374
412
  if (!owners.has(`${request.kind}:${request.name}`))
375
- owners.set(`${request.kind}:${request.name}`, manifest.name)
376
- const unknownOwner = '<package that asks for it>'
413
+ owners.set(`${request.kind}:${request.name}`, request.package)
377
414
  const blocks = new Map<string, { modules: string[]; views: string[] }>()
378
415
  const manual: string[] = []
379
416
  for (const [kind, entries] of [
@@ -395,7 +432,13 @@ export function wireSlotMigration(
395
432
  )
396
433
  continue
397
434
  }
398
- const owner = owners.get(`${kind}:${name}`) ?? unknownOwner
435
+ const owner = owners.get(`${kind}:${name}`)
436
+ if (!owner) {
437
+ manual.push(
438
+ `${name}: no requesting package was found; find the package that requests this ${kind} before adding its packages entry`,
439
+ )
440
+ continue
441
+ }
399
442
  const block = blocks.get(owner) ?? { modules: [], views: [] }
400
443
  block[kind === 'module' ? 'modules' : 'views'].push(
401
444
  `[${JSON.stringify(name)}]: ${stub}`,
@@ -431,3 +474,175 @@ export function readSmallFile(file: string): string | null {
431
474
  return null
432
475
  }
433
476
  }
477
+
478
+ export function hasNativeCode(
479
+ dir: string,
480
+ platform: ScanResult['scoreSummary']['platform'],
481
+ ): boolean {
482
+ const expoConfig = join(dir, 'expo-module.config.json')
483
+ const platforms = metadataValue(readProjectJson(expoConfig), 'platforms')
484
+ if (Array.isArray(platforms))
485
+ return (
486
+ platforms.includes(platform) || (platform === 'ios' && platforms.includes('apple'))
487
+ )
488
+ if ([platform, 'expo-module.config.json'].some((entry) => existsSync(join(dir, entry))))
489
+ return true
490
+ if (listDir(dir).some((name) => name.endsWith('.podspec'))) return true
491
+ const manifest = readSmallFile(join(dir, 'package.json'))
492
+ return manifest !== null && manifest.includes('"codegenConfig"')
493
+ }
494
+
495
+ // an app's own native code, found from the names its JavaScript asks the native
496
+ // side for and from local Expo modules (a directory with expo-module.config.json).
497
+ // each name is a key under its package in peach.config.ts `packages`.
498
+ export interface LocalNative {
499
+ kind: 'module' | 'view'
500
+ name: string
501
+ // the package the name belongs to: the nearest package.json with a name
502
+ package: string
503
+ // first place the name appears, relative to the app, as file:line
504
+ path: string
505
+ // the local Expo module directory the name belongs to, relative to the app
506
+ expoModule: string | null
507
+ swift: boolean
508
+ // a stub already answers it
509
+ answered: boolean
510
+ }
511
+
512
+ const NATIVE_SOURCE_FILE = /\.(?:swift|kt)$/
513
+ export type AnsweredNames = Record<LocalNative['kind'], Set<string>>
514
+
515
+ export function scanLocalNative(
516
+ appDir: string,
517
+ pkg: ProjectManifest,
518
+ answered: AnsweredNames,
519
+ platform: ScanResult['scoreSummary']['platform'] | undefined,
520
+ knownPackages: ReadonlySet<string> = new Set(),
521
+ includeLegacy = false,
522
+ ): LocalNative[] {
523
+ const expoModuleDirs: string[] = []
524
+ const excludedModuleDirs: string[] = []
525
+ const sourceFiles: string[] = []
526
+ for (const root of [appDir, ...workspacePackageDirs(appDir, pkg)]) {
527
+ for (const dir of scanRepository(root).directories) {
528
+ if (
529
+ excludedModuleDirs.some(
530
+ (excluded) => dir === excluded || dir.startsWith(`${excluded}/`),
531
+ )
532
+ )
533
+ continue
534
+ if (existsSync(join(dir, 'expo-module.config.json'))) {
535
+ if (platform && !hasNativeCode(dir, platform)) {
536
+ excludedModuleDirs.push(dir)
537
+ continue
538
+ }
539
+ expoModuleDirs.push(dir)
540
+ }
541
+ for (const name of listDir(dir)) {
542
+ if (SOURCE_FILE.test(name) && !name.endsWith('.d.ts'))
543
+ sourceFiles.push(join(dir, name))
544
+ }
545
+ }
546
+ }
547
+ const nativeFilesByModule = new Map(
548
+ expoModuleDirs.map((dir) => [
549
+ dir,
550
+ scanRepository(dir).directories.flatMap((sub) =>
551
+ listDir(sub)
552
+ .filter((name) => NATIVE_SOURCE_FILE.test(name))
553
+ .map((name) => join(sub, name)),
554
+ ),
555
+ ]),
556
+ )
557
+
558
+ const owners = new Map<string, string>()
559
+ const owner = (dir: string): string => {
560
+ const known = owners.get(dir)
561
+ if (known) return known
562
+ const name = readPackageJson(dir)?.name
563
+ const parent = dirname(dir)
564
+ const result =
565
+ typeof name === 'string'
566
+ ? name
567
+ : parent === dir
568
+ ? (pkg.name ?? 'app')
569
+ : owner(parent)
570
+ owners.set(dir, result)
571
+ return result
572
+ }
573
+ const found = new Map<string, LocalNative>()
574
+ const add = (kind: LocalNative['kind'], name: string, file: string, line: number) => {
575
+ const packageName = owner(dirname(file))
576
+ if (knownPackages.has(packageName)) return
577
+ const key = `${kind}:${name}`
578
+ if (found.has(key)) return
579
+ const moduleDir = expoModuleDirs.find((dir) => file.startsWith(`${dir}/`)) ?? null
580
+ found.set(key, {
581
+ kind,
582
+ name,
583
+ package: packageName,
584
+ path: `${relative(appDir, file)}:${line}`,
585
+ expoModule: moduleDir ? relative(appDir, moduleDir) || '.' : null,
586
+ swift: moduleDir
587
+ ? (nativeFilesByModule.get(moduleDir) ?? []).some((path) =>
588
+ path.endsWith('.swift'),
589
+ )
590
+ : false,
591
+ answered: answered[kind].has(name),
592
+ })
593
+ }
594
+ for (const file of sourceFiles) {
595
+ const text = readSmallFile(file)
596
+ if (!text) continue
597
+ for (const { kind, name, line } of sourceNativeRequests(file, text, includeLegacy))
598
+ add(kind, name, file, line)
599
+ }
600
+ // a local expo module declares its name in its native definition, Name("X"),
601
+ // whether or not the app's javascript spells it out. swift first, so a
602
+ // module the report marks Swift points at its swift definition.
603
+ for (const [, files] of nativeFilesByModule) {
604
+ const swiftFirst = [...files].sort(
605
+ (a, b) => Number(b.endsWith('.swift')) - Number(a.endsWith('.swift')),
606
+ )
607
+ for (const file of swiftFirst) {
608
+ const text = readSmallFile(file)
609
+ if (!text) continue
610
+ for (const match of text.matchAll(/\bName\(\s*"(\w+)"\s*\)/g)) {
611
+ if (match[1])
612
+ add('module', match[1], file, text.slice(0, match.index).split('\n').length)
613
+ }
614
+ }
615
+ }
616
+ return [...found.values()].sort(
617
+ (a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name),
618
+ )
619
+ }
620
+
621
+ // dependencies installed as links to source outside any node_modules are the
622
+ // app's own workspace packages, which hold its native code as often as the app
623
+ // directory does.
624
+ function workspacePackageDirs(appDir: string, pkg: ProjectManifest): string[] {
625
+ const dirs = new Set<string>()
626
+ for (const name of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })) {
627
+ let current = appDir
628
+ while (true) {
629
+ const link = join(current, 'node_modules', name)
630
+ if (existsSync(link)) {
631
+ if (lstatSync(link).isSymbolicLink()) {
632
+ const target = realpathSync(link)
633
+ if (
634
+ !target.split('/').includes('node_modules') &&
635
+ !target.startsWith(`${appDir}/`)
636
+ ) {
637
+ dirs.add(target)
638
+ }
639
+ }
640
+ break
641
+ }
642
+ const parent = dirname(current)
643
+ if (parent === current) break
644
+ current = parent
645
+ }
646
+ }
647
+ return [...dirs]
648
+ }
@@ -2175,48 +2175,58 @@ export class SootSimBridgeFlowRunner {
2175
2175
  await sleep(120)
2176
2176
  }
2177
2177
 
2178
+ // maestro's android driver sends Back. its ios driver never presses a key:
2179
+ // while the keyboard shows it swipes from the screen center up 3%, then
2180
+ // left 3%, and the app's own handling of those touches dismisses it, or
2181
+ // presses whatever sits under the center
2178
2182
  async hideKeyboard() {
2179
- // CLI flows need "dismiss keyboard" to mean "get the focused TextInput
2180
- // out of the way before the next tap", not just "ask shell chrome to
2181
- // animate down eventually". If the TextInput stays focused, the next tap
2182
- // can still be swallowed by keyboard-dismiss policy on ScrollViews.
2183
- try {
2184
- await this.callTest<boolean>('blurFocusedTextInput')
2185
- } catch {
2186
- // fall through to the shell-level dismiss below; older bridges may not
2187
- // expose the text-input helper yet.
2183
+ if (this.js.maestro.platform.toLowerCase() === 'android') {
2184
+ await this.back()
2185
+ await sleep(300)
2186
+ await this.waitForAppToSettle()
2187
+ return
2188
2188
  }
2189
+ if (!(await this.isKeyboardVisible())) return
2190
+ const screen = await this.screenBounds()
2191
+ const center = parsePoint('50%,50%', screen)
2192
+ await this.xctestSwipe(center, parsePoint('50%,47%', screen), 50)
2193
+ if (!(await this.isKeyboardVisible())) return
2194
+ await this.xctestSwipe(center, parsePoint('47%,50%', screen), 50)
2195
+ await this.waitForAppToSettle()
2196
+ }
2189
2197
 
2190
- await this.bridge.send({
2191
- type: 'keyboard',
2192
- simId: this.opts.simId,
2193
- action: 'dismiss',
2194
- })
2198
+ // the xctest runner's swipe: press, move to the end over its 100ms tap
2199
+ // duration, rest there for the requested duration, then lift, so the
2200
+ // finger leaves at rest rather than flinging
2201
+ private async xctestSwipe(
2202
+ from: { x: number; y: number },
2203
+ to: { x: number; y: number },
2204
+ durationMs: number,
2205
+ ) {
2206
+ const moves = 6
2207
+ const steps: PerformStep[] = [{ type: 'touchDown', x: from.x, y: from.y }]
2208
+ for (let i = 1; i <= moves; i++) {
2209
+ steps.push({ type: 'wait', ms: 100 / moves })
2210
+ steps.push({
2211
+ type: 'touchMove',
2212
+ x: from.x + ((to.x - from.x) * i) / moves,
2213
+ y: from.y + ((to.y - from.y) * i) / moves,
2214
+ })
2215
+ }
2216
+ steps.push({ type: 'wait', ms: durationMs })
2217
+ steps.push({ type: 'touchUp', x: to.x, y: to.y })
2218
+ const result = await this.perform(steps)
2219
+ if (!result?.ok) throw new Error(`swipe failed: ${result?.error ?? 'swipe missed'}`)
2220
+ await this.waitForAppToSettle()
2221
+ }
2195
2222
 
2196
- const deadline = Date.now() + 3000
2197
- do {
2198
- // the host test bridge proxies the tenant, so isTextInputFocused
2199
- // resolves a promise; reading it unawaited is always truthy.
2200
- const state = await this.evaluate<{
2201
- visible: boolean
2202
- focused: boolean
2203
- }>(`(async () => {
2204
- const keyboard =
2205
- window.__sootsimKeyboard ??
2206
- window.SootSim?.bridges?.keyboard ??
2207
- null
2208
- const test = window.__sootsimTest
2209
- return {
2210
- visible: !!keyboard?.isVisible?.(),
2211
- focused: !!(await test?.isTextInputFocused?.()),
2212
- }
2213
- })()`)
2214
- if (!state?.visible && !state?.focused) {
2215
- await sleep(80)
2216
- return
2217
- }
2218
- await sleep(80)
2219
- } while (Date.now() < deadline)
2223
+ private async isKeyboardVisible(): Promise<boolean> {
2224
+ const visible = await this.evaluate<boolean>(`(() => {
2225
+ const keyboard =
2226
+ window.__sootsimKeyboard ?? window.SootSim?.bridges?.keyboard ?? null
2227
+ return !!keyboard?.isVisible?.()
2228
+ })()`)
2229
+ return visible === true
2220
2230
  }
2221
2231
 
2222
2232
  async eraseText(count: number) {
@@ -2765,8 +2775,15 @@ export class SootSimBridgeFlowRunner {
2765
2775
  : { x: centerX, y: screen.height * 0.1 }
2766
2776
  const element = await this.findElement(target)
2767
2777
  if (element) {
2768
- const { x, y } = element.absolutePosition
2769
- const { width, height } = element.layout
2778
+ // maestro computes visibility from integer edges, as it does selection.
2779
+ const { x, y, width, height } = accessibilityBounds({
2780
+ frame: {
2781
+ x: element.absolutePosition.x,
2782
+ y: element.absolutePosition.y,
2783
+ width: element.layout.width,
2784
+ height: element.layout.height,
2785
+ },
2786
+ })
2770
2787
  const shownX = Math.max(0, Math.min(x + width, screen.width) - Math.max(x, 0))
2771
2788
  const shownY = Math.max(0, Math.min(y + height, screen.height) - Math.max(y, 0))
2772
2789
  const share = width * height > 0 ? (shownX * shownY) / (width * height) : 0
@@ -2892,6 +2909,12 @@ export class SootSimBridgeFlowRunner {
2892
2909
  const wantsClear = !!(opts && typeof opts === 'object' && opts.clearState)
2893
2910
  const wantsResetRuntime = readBooleanKey(opts, 'resetRuntime')
2894
2911
  const wantsForeground = isObjectRecord(opts) && opts.stopApp === false
2912
+ // maestro's launchApp clears the keychain, then app state, then sets
2913
+ // permissions and launches
2914
+ if (readBooleanKey(opts, 'clearKeychain')) {
2915
+ if (!this.firstLaunchDone && !this.appStopped) await this.waitForTree(120000)
2916
+ await this.clearKeychain()
2917
+ }
2895
2918
  await this.setLaunchPermissions(isObjectRecord(opts) ? opts.permissions : undefined)
2896
2919
 
2897
2920
  if (!this.firstLaunchDone) {
@@ -2982,9 +3005,7 @@ export class SootSimBridgeFlowRunner {
2982
3005
 
2983
3006
  private async setLaunchPermissions(permissions: unknown) {
2984
3007
  const statuses = maestroLaunchPermissions(permissions)
2985
- const state = await getShellState(this.bridge)
2986
- const appId = readStringKey(state, 'activeApp') || readRecentShellAppId(state)
2987
- if (!appId) throw new Error('launchApp: no simulator app to set permissions for')
3008
+ const appId = await this.callTest<string>('permissionGuestAppId')
2988
3009
  for (const [kind, status] of statuses) {
2989
3010
  await this.callTest('permissionSetAppPermission', appId, kind, {
2990
3011
  status,
@@ -128,6 +128,7 @@ const VALUE_FLAGS = new Set([
128
128
  '--timeout',
129
129
  '--device',
130
130
  '--posture',
131
+ '--render-resolution',
131
132
  '--os-version',
132
133
  '--ios-target',
133
134
  '--output',