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.
- package/README.md +21 -2
- package/cli/app-config.ts +25 -9
- package/cli/bridge-flow-runner.ts +10 -23
- package/cli/commands/compat.ts +234 -10
- package/cli/commands/do-chain.ts +5 -1
- package/cli/commands/inspect/actions.ts +33 -76
- package/cli/commands/inspect/resolve-target.ts +67 -2
- package/cli/commands/inspect.ts +16 -1
- package/cli/drivers/playwright-provisioning.ts +95 -0
- package/cli/ws-bridge.ts +7 -7
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +9 -1
- package/dist-lib/bridge-contract-input.mjs +9 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +3 -2
- package/dist-lib/detox/index.cjs +2 -2
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +78 -35
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +51 -8
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +317 -70
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +14 -4
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +300 -29
- package/dist-lib/native-app-identity.cjs +58 -25
- package/dist-lib/native-app-identity.mjs +57 -25
- package/dist-lib/plist.cjs +64 -0
- package/dist-lib/plist.mjs +29 -0
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +2 -2
- package/dist-lib/sdk.cjs +36 -49
- package/dist-lib/sdk.mjs +36 -49
- package/dist-lib/skills.cjs +257 -138
- package/dist-lib/swift-compile.cjs +1 -1
- package/dist-lib/swift-compile.mjs +1 -1
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +51 -8
- package/kotlin/AppSources/android/app/Activity.kt +44 -0
- package/kotlin/AppSources/android/app/UiModeManager.kt +9 -0
- package/kotlin/AppSources/android/content/Context.kt +9 -2
- package/kotlin/AppSources/android/content/res/Configuration.kt +16 -0
- package/kotlin/AppSources/android/content/res/Resources.kt +7 -0
- package/kotlin/AppSources/android/graphics/Color.kt +13 -0
- package/kotlin/AppSources/android/os/Bundle.kt +9 -3
- package/kotlin/AppSources/android/view/View.kt +2 -0
- package/kotlin/AppSources/android/view/Window.kt +99 -8
- package/kotlin/AppSources/android/view/WindowInsetsController.kt +12 -0
- package/kotlin/AppSources/android/view/WindowManager.kt +16 -0
- package/kotlin/AppSources/androidx/activity/BackEventCompat.kt +17 -0
- package/kotlin/AppSources/androidx/activity/Cancellable.kt +5 -0
- package/kotlin/AppSources/androidx/activity/ComponentActivity.kt +22 -3
- package/kotlin/AppSources/androidx/activity/EdgeToEdge.kt +71 -0
- package/kotlin/AppSources/androidx/activity/OnBackPressedCallback.kt +28 -0
- package/kotlin/AppSources/androidx/activity/OnBackPressedDispatcher.kt +144 -0
- package/kotlin/AppSources/androidx/activity/OnBackPressedDispatcherOwner.kt +7 -0
- package/kotlin/AppSources/androidx/activity/compose/BackHandler.kt +55 -0
- package/kotlin/AppSources/androidx/core/view/WindowCompat.kt +32 -0
- package/kotlin/AppSources/dev/peach/app/PeachApp.kt +67 -12
- package/kotlin/AppSources/dev/peach/app/PeachWindowHost.kt +2 -1
- package/kotlin/AppSources/java/lang/Runnable.kt +6 -0
- package/kotlin/ComposeSources/PeachComposeHost.kt +11 -1
- package/package.json +8 -1
- package/skills/peach-setup/SKILL.md +27 -0
- package/src/android-theme.ts +167 -0
- package/src/android-xml.ts +70 -0
- package/src/bridge-contract-input.ts +10 -1
- package/src/bridge-contract.ts +7 -1
- package/src/config.ts +8 -0
- package/src/generated/android-framework-themes.json +6303 -0
- package/src/host/replacement-module-handler.ts +33 -0
- package/src/index.ts +1 -0
- package/src/kotlin-app.ts +86 -61
- package/src/kotlin-host.ts +13 -3
- package/src/metro-plugin.ts +46 -3
- package/src/native-app-identity.ts +75 -26
- 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.
|
|
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.
|
|
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:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
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
|
|
package/cli/commands/compat.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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,
|
package/cli/commands/do-chain.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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 (!
|
|
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,
|