peachsim 0.1.1477 → 0.1.1481

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 (96) hide show
  1. package/cli/commands/control.ts +68 -42
  2. package/cli/commands/detox.ts +15 -10
  3. package/cli/commands/flow.ts +1 -2
  4. package/cli/commands/inspect/settling.ts +5 -1
  5. package/cli/commands/setup.ts +20 -13
  6. package/cli/commands/test.ts +2 -1
  7. package/cli/dev-build-freshness.ts +14 -14
  8. package/cli/outbound-endpoints.ts +80 -6
  9. package/detox/index.ts +27 -14
  10. package/detox/matchers.ts +17 -6
  11. package/dist-lib/agent-daemon-client.cjs +1 -1
  12. package/dist-lib/agent-events.cjs +1 -1
  13. package/dist-lib/attached-projects.cjs +1 -1
  14. package/dist-lib/auth/shared-session.cjs +1 -1
  15. package/dist-lib/backend-origin.cjs +1 -1
  16. package/dist-lib/beta.cjs +1 -1
  17. package/dist-lib/beta.mjs +1 -1
  18. package/dist-lib/bridge-constants.cjs +1 -1
  19. package/dist-lib/bridge-contract-input.cjs +1 -1
  20. package/dist-lib/bridge-contract-input.mjs +1 -1
  21. package/dist-lib/bridge-contract.cjs +1 -1
  22. package/dist-lib/bridge-contract.mjs +1 -1
  23. package/dist-lib/bundle-channel.cjs +42 -14
  24. package/dist-lib/capture-contract.cjs +1 -1
  25. package/dist-lib/capture-contract.mjs +1 -1
  26. package/dist-lib/cloud-contract.cjs +1 -1
  27. package/dist-lib/cloud-contract.mjs +1 -1
  28. package/dist-lib/cloud-test-args.cjs +1 -1
  29. package/dist-lib/cloud-test-args.mjs +1 -1
  30. package/dist-lib/cloud.cjs +1 -1
  31. package/dist-lib/cloud.mjs +1 -1
  32. package/dist-lib/config.cjs +19 -3
  33. package/dist-lib/config.mjs +19 -3
  34. package/dist-lib/detox/index.cjs +80 -13
  35. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  36. package/dist-lib/expo-swift-bundle.cjs +24 -8
  37. package/dist-lib/expo-swift-metadata.cjs +18 -6
  38. package/dist-lib/expo-swift-metadata.mjs +18 -6
  39. package/dist-lib/home-paths.cjs +1 -1
  40. package/dist-lib/host/bridge-host.cjs +489 -355
  41. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  42. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  43. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  44. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  45. package/dist-lib/host/websocket-proxy.cjs +1 -1
  46. package/dist-lib/index.cjs +46 -19
  47. package/dist-lib/jump-to-source-babel.cjs +1 -1
  48. package/dist-lib/jump-to-source-native.cjs +1 -1
  49. package/dist-lib/kotlin-host.cjs +1 -1
  50. package/dist-lib/menu.cjs +1 -1
  51. package/dist-lib/menu.mjs +1 -1
  52. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  53. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  54. package/dist-lib/metro-production-bundle.cjs +24 -6
  55. package/dist-lib/metro-production-bundle.mjs +22 -6
  56. package/dist-lib/metro.cjs +41 -18
  57. package/dist-lib/native-app-identity-project.cjs +1 -1
  58. package/dist-lib/native-app-identity-project.mjs +1 -1
  59. package/dist-lib/native-app-identity.cjs +19 -5
  60. package/dist-lib/native-app-identity.mjs +19 -5
  61. package/dist-lib/native-firebase-metadata.cjs +22 -7
  62. package/dist-lib/native-firebase-metadata.mjs +20 -7
  63. package/dist-lib/plist.cjs +1 -1
  64. package/dist-lib/plist.mjs +1 -1
  65. package/dist-lib/profiles.cjs +1 -1
  66. package/dist-lib/public-brand.cjs +1 -1
  67. package/dist-lib/react-native-host-modules.cjs +3 -1
  68. package/dist-lib/react-native-host-modules.mjs +3 -1
  69. package/dist-lib/scripts/dev-server-scanner.cjs +404 -279
  70. package/dist-lib/sdk.cjs +6 -2
  71. package/dist-lib/sdk.mjs +6 -2
  72. package/dist-lib/skills.cjs +618 -440
  73. package/dist-lib/swift-compile.cjs +1 -1
  74. package/dist-lib/swift-compile.mjs +1 -1
  75. package/dist-lib/swift-package.cjs +1 -1
  76. package/dist-lib/swift-package.mjs +1 -1
  77. package/dist-lib/swift-submit.cjs +1 -1
  78. package/dist-lib/swift-submit.mjs +1 -1
  79. package/dist-lib/swift-toolchain.cjs +1 -1
  80. package/dist-lib/swift.cjs +1 -1
  81. package/dist-lib/vite-swift.cjs +1 -1
  82. package/dist-lib/vite.cjs +1 -1
  83. package/dist-lib/worklet-executable.cjs +27 -7
  84. package/dist-lib/worklet-executable.mjs +27 -7
  85. package/package.json +1 -1
  86. package/scripts/dev-server-scanner.ts +493 -188
  87. package/src/bundleGlobalFooter.ts +26 -3
  88. package/src/config.ts +3 -2
  89. package/src/configured-bundle-producer.ts +2 -1
  90. package/src/expo-swift-host.ts +33 -7
  91. package/src/metro-production-bundle.ts +11 -4
  92. package/src/native-app-identity.ts +3 -2
  93. package/src/native-firebase-metadata.ts +5 -3
  94. package/src/react-native-host-modules.ts +2 -0
  95. package/src/worker-system-chunks.ts +53 -51
  96. package/src/worklet-executable.ts +38 -6
@@ -16,7 +16,7 @@ import {
16
16
  type DeviceModel,
17
17
  } from 'peach-engine/settings/devices'
18
18
  import { WebSocket } from 'ws'
19
- import { probePort } from '../../scripts/dev-server-scanner'
19
+ import { pinSharedListenerScan, probePort } from '../../scripts/dev-server-scanner'
20
20
  import { isAppDeepLinkTarget, openAppUrl } from '../../src/app-url'
21
21
  import { isLoopbackHost, loopbackOriginOf } from '../../src/backend-origin'
22
22
  import { resolveSootsimShellBaseUrlForBridgePort } from '../../src/bridge-constants'
@@ -260,16 +260,12 @@ async function connectBridgeForOpen(
260
260
  // a closed port refuses at once, so this window only matters when something
261
261
  // listens: a live bridge on a loaded machine can take longer than 250 ms to
262
262
  // finish the handshake, and missing it spawned a second bridge.
263
- if (
264
- (explicitPort || preferredWorld !== null) &&
265
- (await canConnectToBridge(preferredPort, 2_000))
266
- ) {
263
+ const handshake = await bridgeHandshake(preferredPort, 2_000)
264
+ if ((explicitPort || preferredWorld !== null) && handshake === 'open') {
267
265
  return preferredPort
268
266
  }
269
267
  if (explicitPort && preferredWorld !== null) {
270
- throw new Error(
271
- `peach open: requested bridge port ${preferredPort} is not reachable.`,
272
- )
268
+ throw new Error(unreachableBridgeMessage(preferredPort, handshake))
273
269
  }
274
270
  if (
275
271
  !explicitPort &&
@@ -404,23 +400,45 @@ async function ensureRuntimeForOpen() {
404
400
  await runRuntime(['install'], {})
405
401
  }
406
402
 
407
- function canConnectToBridge(port: number, timeoutMs: number): Promise<boolean> {
403
+ type BridgeHandshake = 'open' | 'refused' | 'timeout'
404
+
405
+ function bridgeHandshake(port: number, timeoutMs: number): Promise<BridgeHandshake> {
408
406
  return new Promise((resolve) => {
409
407
  const ws = new WebSocket(`ws://127.0.0.1:${port}`, { handshakeTimeout: timeoutMs })
410
408
  let settled = false
411
- const finish = (ok: boolean) => {
409
+ const finish = (result: BridgeHandshake) => {
412
410
  if (settled) return
413
411
  settled = true
412
+ clearTimeout(timer)
414
413
  if (ws.readyState === WebSocket.OPEN) ws.close()
415
414
  else if (ws.readyState === WebSocket.CONNECTING) ws.terminate()
416
- resolve(ok)
415
+ resolve(result)
417
416
  }
418
- ws.once('open', () => finish(true))
419
- ws.on('error', () => finish(false))
420
- setTimeout(() => finish(false), timeoutMs)
417
+ const timer = setTimeout(() => finish('timeout'), timeoutMs)
418
+ ws.once('open', () => finish('open'))
419
+ ws.on('error', (err: NodeJS.ErrnoException) => {
420
+ finish(
421
+ err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' ? 'refused' : 'timeout',
422
+ )
423
+ })
421
424
  })
422
425
  }
423
426
 
427
+ function unreachableBridgeMessage(port: number, handshake: BridgeHandshake): string {
428
+ if (handshake === 'timeout') {
429
+ const cores = Math.max(1, os.cpus().length)
430
+ const load = os.loadavg()[0]
431
+ if (load >= cores) {
432
+ return `peach open: requested bridge port ${port} did not finish its handshake within 2s while load average was ${load.toFixed(1)} on ${cores} cores.`
433
+ }
434
+ }
435
+ return `peach open: requested bridge port ${port} is not reachable.`
436
+ }
437
+
438
+ function canConnectToBridge(port: number, timeoutMs: number): Promise<boolean> {
439
+ return bridgeHandshake(port, timeoutMs).then((result) => result === 'open')
440
+ }
441
+
424
442
  function looksLikeSootsimUrl(target: string): boolean {
425
443
  try {
426
444
  const url = new URL(target)
@@ -531,39 +549,46 @@ async function resolveConnectionInputForCli(target: string): Promise<ResolvedDev
531
549
 
532
550
  console.log(` resolving native bundle for ${normalized} (manifest timeout: 30s)…`)
533
551
  let lastError: Error | null = null
534
- for (let attempt = 0; attempt < DEV_BUNDLE_RESOLVE_ATTEMPTS; attempt++) {
535
- if (registeredPort && registeredPort > 0) {
536
- const discovered = await probePort(registeredPort)
537
- // a provisional bundle URL is enough to LIST the server but not to open
538
- // it; fall through to resolveConnectionInput, which asks the manifest
539
- // again with a budget that fits a real dev server.
540
- if (discovered && !discovered.bundleUrlProvisional) {
541
- return {
542
- bundleUrl: discovered.bundleUrl,
543
- port: discovered.port,
544
- framework: discovered.framework,
545
- projectName: discovered.projectName,
552
+ const releaseListenerScan = pinSharedListenerScan()
553
+ try {
554
+ for (let attempt = 0; attempt < DEV_BUNDLE_RESOLVE_ATTEMPTS; attempt++) {
555
+ if (registeredPort && registeredPort > 0) {
556
+ const discovered = await probePort(registeredPort)
557
+ // a provisional bundle URL is enough to LIST the server but not to open
558
+ // it; fall through to resolveConnectionInput, which asks the manifest
559
+ // again with a budget that fits a real dev server.
560
+ if (discovered && !discovered.bundleUrlProvisional) {
561
+ return {
562
+ bundleUrl: discovered.bundleUrl,
563
+ port: discovered.port,
564
+ framework: discovered.framework,
565
+ projectName: discovered.projectName,
566
+ }
546
567
  }
547
568
  }
548
- }
549
569
 
550
- try {
551
- return await resolveConnectionInput(normalized)
552
- } catch (error) {
553
- if (
554
- !(error instanceof Error) ||
555
- !error.message.startsWith('could not resolve a native bundle')
556
- ) {
557
- throw error
570
+ try {
571
+ return await resolveConnectionInput(normalized)
572
+ } catch (error) {
573
+ if (
574
+ !(error instanceof Error) ||
575
+ !error.message.startsWith('could not resolve a native bundle')
576
+ ) {
577
+ throw error
578
+ }
579
+ lastError = error
558
580
  }
559
- lastError = error
581
+ await sleep(DEV_BUNDLE_RESOLVE_INTERVAL_MS)
560
582
  }
561
- await sleep(DEV_BUNDLE_RESOLVE_INTERVAL_MS)
583
+ throw (
584
+ lastError ??
585
+ new Error(
586
+ 'could not resolve a native bundle for ' + normalized + ' after retrying.',
587
+ )
588
+ )
589
+ } finally {
590
+ releaseListenerScan()
562
591
  }
563
- throw (
564
- lastError ??
565
- new Error('could not resolve a native bundle for ' + normalized + ' after retrying.')
566
- )
567
592
  }
568
593
 
569
594
  // a dev server builds an app's graph on the first request for its bundle and
@@ -2132,7 +2157,8 @@ export async function runOpenCommand(
2132
2157
  const currentEphemeral = new URL(
2133
2158
  deriveCurrentSimBaseUrl(targetSim, baseUrl),
2134
2159
  ).searchParams.get('ephemeral')
2135
- if (currentEphemeral) navigationUrl.searchParams.set('ephemeral', currentEphemeral)
2160
+ if (currentEphemeral)
2161
+ navigationUrl.searchParams.set('ephemeral', currentEphemeral)
2136
2162
  navigatedShellUrl = navigationUrl.toString()
2137
2163
  navigationUrl.searchParams.set('inspectOpen', token)
2138
2164
  bridge
@@ -19,8 +19,8 @@ import { rnxPublicBrand } from '../../src/public-brand'
19
19
  import { resolveCliAuth } from '../auth'
20
20
  import { registerRun, shouldRegisterRun } from '../run-registry'
21
21
  import { peachExit } from '../run-rnx'
22
- import { buildShellUrl } from './control'
23
- import { discoverSootsimUrl } from './flow'
22
+ import { resolveDefaultBridgePort } from '../ws-bridge'
23
+ import { buildShellUrl, resolveShellBaseUrlForBridgePort } from './control'
24
24
  import { resolveDefaultUploadOrigin } from './upload'
25
25
 
26
26
  interface RunDetoxOpts {
@@ -35,7 +35,7 @@ usage:
35
35
  peach detox [testFiles...] run all tests under e2e/, test/e2e/, or detox/
36
36
  peach detox --app <port|url> the app each device.launchApp() opens
37
37
  peach detox --config <path> use a specific jest config
38
- peach detox --port, -p <port> the peach shell port (default 5173)
38
+ peach detox --port, -p <port> bridge port (defaults to the active local bridge)
39
39
  peach detox init scaffold peach-detox.config.cjs + sample test
40
40
  peach detox --watch jest watch mode
41
41
  peach detox --headed keep the peach shell window visible
@@ -77,7 +77,8 @@ export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise
77
77
  Number(getFlag('--port') || getFlag('-p')) ||
78
78
  opts.port ||
79
79
  Number(process.env.SOOTSIM_PORT) ||
80
- 5173
80
+ resolveDefaultBridgePort()
81
+ const shellBaseUrl = process.env.PEACH_URL || resolveShellBaseUrlForBridgePort(port)
81
82
  if (getFlag('--sim')) {
82
83
  throw new Error(
83
84
  'Detox owns its browser context; pin its shell with PEACH_URL and --device instead of --sim',
@@ -169,7 +170,7 @@ export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise
169
170
  // the driver navigates to a running peach shell; warn before jest starts
170
171
  // when none is reachable.
171
172
  if (!noLaunch) {
172
- await ensureShellRunning(port)
173
+ await ensureShellRunning(shellBaseUrl)
173
174
  }
174
175
 
175
176
  // build the jest command. prefer the user's config if they passed --config
@@ -251,7 +252,6 @@ export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise
251
252
  console.log(` port: ${port}${headed ? ' (headed)' : ''}`)
252
253
  if (localConfig) console.log(` config: ${localConfig}`)
253
254
 
254
- const shellBaseUrl = process.env.PEACH_URL || `http://localhost:${port}`
255
255
  const app = getFlag('--app')
256
256
  const shellUrl = new URL(app ? await buildShellUrl(app, shellBaseUrl) : shellBaseUrl)
257
257
  const device = getFlag('--device')
@@ -447,12 +447,17 @@ function firstExisting(names: string[]): string | null {
447
447
  return null
448
448
  }
449
449
 
450
- async function ensureShellRunning(port: number): Promise<void> {
451
- const shellUrl = await discoverSootsimUrl()
452
- if (shellUrl) return
450
+ async function ensureShellRunning(shellUrl: string): Promise<void> {
451
+ try {
452
+ const response = await fetch(shellUrl, { signal: AbortSignal.timeout(3000) })
453
+ await response.body?.cancel()
454
+ if (response.ok) return
455
+ } catch {
456
+ // an unreachable selected shell warns before jest owns its launch failure.
457
+ }
453
458
 
454
459
  console.warn(
455
- ` warn: no peach shell reachable on :${port}. start your app, then run \`peach open <app-port>\`\n` +
460
+ ` warn: no peach shell reachable at ${shellUrl}. start your app, then run \`peach open <app-port>\`\n` +
456
461
  ` or pass --no-launch if you're managing the shell yourself.`,
457
462
  )
458
463
  }
@@ -197,8 +197,7 @@ export async function resolveAppFrontmatterTarget(
197
197
  return { target: trimmed }
198
198
  }
199
199
 
200
- // discover a peach shell URL — prefer patched servers, fall back to any match.
201
- // exported so `detox.ts` and `maestro.ts` can reuse the same discovery logic.
200
+ // discover a peach shell URL from running dev servers, preferring patched ones.
202
201
  export async function discoverSootsimUrl(): Promise<string | null> {
203
202
  const servers = await scanDevServers()
204
203
  const patched = servers.find((s) => s.patched)
@@ -265,7 +265,11 @@ export async function waitForSootsimIdle({
265
265
  for (const n of hierarchy) {
266
266
  const layout = n && n.layout
267
267
  if (layout && layout.width > 0) {
268
- const abs = (n && (n.absolute || n.absolutePosition)) || layout
268
+ // painted bounds catch a scroll fling. background playback
269
+ // keeps a view's painted bounds moving forever, so it and its
270
+ // subtree count by their own layout; the rest of the screen
271
+ // still catches the fling.
272
+ const abs = n.backgroundPlayback ? layout : n.absolute || n.absolutePosition || layout
269
273
  nodes.push(Math.round(abs.x) + ',' + Math.round(abs.y) + ',' + Math.round(layout.width))
270
274
  }
271
275
  notePreviewProgress(
@@ -177,11 +177,9 @@ export async function runSetup(
177
177
  const productAnalytics = decidePrivacy(options, privacy)
178
178
  const crashReports = productAnalytics
179
179
 
180
- const repositoryPlan = packageRoot
181
- ? planRepositorySetup({ appDir: packageRoot, cliVersion })
182
- : null
183
- const repository = await decideRepository(options, repositoryPlan)
184
- if (repository && repositoryPlan) {
180
+ const repositoryPlan = await decideRepository(options, packageRoot, cliVersion)
181
+ const repository = repositoryPlan !== null
182
+ if (repositoryPlan) {
185
183
  if (!options.json) {
186
184
  for (const operation of formatRepositoryOperations(repositoryPlan)) {
187
185
  console.log(` ${muted(options.dryRun ? 'Would run' : 'Running')} ${operation}`)
@@ -376,22 +374,31 @@ function decidePrivacy(
376
374
 
377
375
  async function decideRepository(
378
376
  options: SetupOptions,
379
- plan: RepositorySetupPlan | null,
380
- ): Promise<boolean> {
381
- if (!plan) {
377
+ appDir: string | null,
378
+ cliVersion: string,
379
+ ): Promise<RepositorySetupPlan | null> {
380
+ if (!appDir) {
382
381
  if (options.repository === true) {
383
382
  throw new Error('--repo requires a package.json inside a Git repository')
384
383
  }
385
- return false
384
+ return null
385
+ }
386
+ if (options.repository === false) return null
387
+ if (
388
+ options.repository === undefined &&
389
+ (options.yes || options.ci || !process.stdin.isTTY)
390
+ ) {
391
+ return null
386
392
  }
387
- if (plan.operations.length === 0) return true
388
- if (options.repository !== undefined) return options.repository
389
- if (options.yes || options.ci || !process.stdin.isTTY) return false
393
+ const plan = planRepositorySetup({ appDir, cliVersion })
394
+ if (plan.operations.length === 0 || options.repository === true) return plan
390
395
  printPromptBlock('Share peach with your team', [
391
396
  'peach already works here with no project setup.',
392
397
  'This only adds an exact peachsim dev dependency and a peach script for teammates.',
393
398
  ])
394
- return confirm(`Add peach to ${path.basename(plan.appDir)}?`, false)
399
+ return (await confirm(`Add peach to ${path.basename(plan.appDir)}?`, false))
400
+ ? plan
401
+ : null
395
402
  }
396
403
 
397
404
  function requireInteractive(options: SetupOptions, flags: string): void {
@@ -15,6 +15,7 @@ import {
15
15
  publishLiveDevStack,
16
16
  } from '../cloud-test-tunnel'
17
17
  import { parseFlowFile } from '../flow-file'
18
+ import { resolveDefaultBridgePort } from '../ws-bridge'
18
19
  import { buildShellUrl, resolveShellBaseUrlForBridgePort } from './control'
19
20
  import { FLOW_ARG_VALUE_FLAGS, hoistLeadingSimFlag } from './flow'
20
21
  import { resolveDefaultUploadOrigin } from './upload'
@@ -1271,7 +1272,7 @@ export async function runTest(
1271
1272
  process.env.PEACH_URL = await buildShellUrl(
1272
1273
  appTarget.target,
1273
1274
  resolveShellBaseUrlForBridgePort(
1274
- opts.port ?? (Number(process.env.SOOTSIM_PORT) || 5173),
1275
+ opts.port ?? (Number(process.env.SOOTSIM_PORT) || resolveDefaultBridgePort()),
1275
1276
  ),
1276
1277
  )
1277
1278
  }
@@ -8,14 +8,11 @@
8
8
  // artifact mtimes per build route and reports which routes are stale, so
9
9
  // serve can refuse before binding instead of serving yesterday's engine.
10
10
  //
11
- // the route table mirrors scripts/dev/watch-peach-artifacts.ts ROUTES (the
12
- // authority on which sources feed which build). it is duplicated here rather
13
- // than imported because the watcher pulls chokidar plus engine/bundler
14
- // modules the CLI binary must not bundle — keep the two in sync: a source
15
- // the watch rebuilds on must appear below, or serve will call a stale build
16
- // fresh. deliberately omitted: the canvaskit binary inputs (resolved from a
17
- // manifest at watch time, never hand-edited) and the deps-web inputs (a
18
- // different served tree, not the engine serve() serves).
11
+ // the watcher routes individual native inputs through its canonical providers.
12
+ // this guard covers vendor glue/manifests and public assets as directories so
13
+ // a native artifact update also refuses stale served workers. importing the
14
+ // watcher would bundle chokidar and private engine/bundler modules into the cli.
15
+ // deps-web belongs to a different served tree.
19
16
 
20
17
  import { readdirSync, statSync, type Dirent } from 'node:fs'
21
18
  import { join, relative, sep } from 'node:path'
@@ -53,8 +50,11 @@ const PEACHSIM_ENGINE_INPUTS = [
53
50
  const ENGINE_SOURCES = [
54
51
  'packages/peach-engine/src',
55
52
  'packages/peach-engine/public',
53
+ 'packages/peach-engine/vendor',
56
54
  'packages/peach-engine/scripts/license-banner.ts',
57
55
  'packages/peach-engine/scripts/canvaskit-graphite-variant.ts',
56
+ 'packages/peach-engine/scripts/canvaskit-ganesh-variant.ts',
57
+ 'packages/peach-engine/scripts/canvaskit-kotlin-variant.ts',
58
58
  'packages/peach-engine/vite.build.config.ts',
59
59
  'packages/peach-engine/package.json',
60
60
  'packages/peach-compat/src',
@@ -176,7 +176,7 @@ export function findStaleDevBuildRoutes(monorepoRoot: string): StaleDevBuildRout
176
176
  newestSource = found
177
177
  }
178
178
  }
179
- let newestArtifact: NewestFile | null = null
179
+ let oldestArtifact: NewestFile | null = null
180
180
  let artifactMissing = false
181
181
  for (const artifact of route.artifacts) {
182
182
  const found = newestForEntry(monorepoRoot, artifact)
@@ -184,20 +184,20 @@ export function findStaleDevBuildRoutes(monorepoRoot: string): StaleDevBuildRout
184
184
  artifactMissing = true
185
185
  continue
186
186
  }
187
- if (!newestArtifact || found.mtimeMs > newestArtifact.mtimeMs) {
188
- newestArtifact = found
187
+ if (!oldestArtifact || found.mtimeMs < oldestArtifact.mtimeMs) {
188
+ oldestArtifact = found
189
189
  }
190
190
  }
191
191
  if (!newestSource) continue
192
192
  if (
193
193
  artifactMissing ||
194
- !newestArtifact ||
195
- newestSource.mtimeMs > newestArtifact.mtimeMs
194
+ !oldestArtifact ||
195
+ newestSource.mtimeMs > oldestArtifact.mtimeMs
196
196
  ) {
197
197
  stale.push({
198
198
  route: route.id,
199
199
  newestSourcePath: toRepoPath(monorepoRoot, newestSource.absolutePath),
200
- artifactMtimeMs: newestArtifact ? newestArtifact.mtimeMs : null,
200
+ artifactMtimeMs: oldestArtifact ? oldestArtifact.mtimeMs : null,
201
201
  })
202
202
  }
203
203
  }
@@ -121,7 +121,7 @@ export const OUTBOUND_ENDPOINTS: Record<string, OutboundEndpointDeclaration> = {
121
121
  category: 'same-origin-runtime',
122
122
  destination: 'the origin already serving the peach runtime',
123
123
  preConsent: true,
124
- payload: 'runtime assets and shared local settings',
124
+ payload: 'runtime assets, selected-shell reachability, and shared local settings',
125
125
  },
126
126
  guest_app_network: {
127
127
  category: 'guest-app-network',
@@ -552,12 +552,25 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
552
552
  { category: 'same_origin_runtime', count: 1 },
553
553
  // the tenant build's built-in app chunk prefetch list and the chunk bodies
554
554
  // it names, resolved against the worker chunk's own url.
555
- 'packages/peach-engine/src/render-worker/app-in-worker.tsx :: fetch :: listUrl': {
555
+ 'packages/peach-engine/src/render-worker/app-in-worker.tsx :: fetch-alias :: listUrl': {
556
556
  category: 'same_origin_runtime',
557
557
  count: 1,
558
558
  },
559
- 'packages/peach-engine/src/render-worker/app-in-worker.tsx :: fetch :: new URL(file, listUrl)':
559
+ 'packages/peach-engine/src/render-worker/app-in-worker.tsx :: fetch-alias :: new URL(file, listUrl)':
560
560
  { category: 'same_origin_runtime', count: 1 },
561
+ // the tenant build's own chunks a dynamic import names, resolved against the
562
+ // importing chunk's url.
563
+ 'packages/peach-engine/src/render-worker/module-preload.ts :: fetch-alias :: url': {
564
+ category: 'same_origin_runtime',
565
+ count: 1,
566
+ },
567
+ // guest fetch wrappers forward the guest's own request to the host fetch.
568
+ 'packages/peach-engine/src/headless/tenant.ts :: fetch-alias :: activeInput as RequestInfo':
569
+ { category: 'guest_app_network', count: 1 },
570
+ 'packages/peach-engine/src/render-worker/worker.ts :: fetch-alias :: input': {
571
+ category: 'guest_app_network',
572
+ count: 1,
573
+ },
561
574
  'packages/peach-engine/src/render-worker/rive-animation-clock.ts :: fetch :: wasmUrl': {
562
575
  category: 'same_origin_runtime',
563
576
  count: 1,
@@ -630,6 +643,10 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
630
643
  category: 'local_development',
631
644
  count: 1,
632
645
  },
646
+ 'packages/peachsim/cli/commands/detox.ts :: fetch :: shellUrl': {
647
+ category: 'same_origin_runtime',
648
+ count: 1,
649
+ },
633
650
  'packages/peachsim/cli/commands/cpu-profile.ts :: fetch :: `http://localhost:${cdpPort}/json/list`':
634
651
  { category: 'local_development', count: 1 },
635
652
  'packages/peachsim/cli/commands/cpu-profile.ts :: websocket :: wsUrl': {
@@ -821,7 +838,51 @@ interface NetworkNames {
821
838
  nodeGet: Set<string>
822
839
  }
823
840
 
824
- function networkNames(source: Program): NetworkNames {
841
+ // `<owner>.fetch.bind(...)` captures the host fetch under another name
842
+ function isFetchBinding(node: Expression): boolean {
843
+ return (
844
+ node.type === 'CallExpression' &&
845
+ node.callee.type === 'MemberExpression' &&
846
+ !node.callee.computed &&
847
+ node.callee.property.type === 'Identifier' &&
848
+ node.callee.property.name === 'bind' &&
849
+ node.callee.object.type === 'MemberExpression' &&
850
+ !node.callee.object.computed &&
851
+ node.callee.object.property.type === 'Identifier' &&
852
+ node.callee.object.property.name === 'fetch'
853
+ )
854
+ }
855
+
856
+ // names a relative module exports as fetch aliases, so a call through the
857
+ // imported binding is declared like a direct fetch.
858
+ const exportedFetchAliasCache = new Map<string, Set<string>>()
859
+ function exportedFetchAliases(importer: string, specifier: string): Set<string> {
860
+ if (!specifier.startsWith('.')) return new Set()
861
+ const base = path.resolve(path.dirname(importer), specifier)
862
+ const file = [`${base}.ts`, `${base}.tsx`, base, path.join(base, 'index.ts')].find(
863
+ (candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile(),
864
+ )
865
+ if (!file) return new Set()
866
+ const cached = exportedFetchAliasCache.get(file)
867
+ if (cached) return cached
868
+ const aliases = new Set<string>()
869
+ exportedFetchAliasCache.set(file, aliases)
870
+ const program = parseSync(file, fs.readFileSync(file, 'utf8'), {
871
+ lang: file.endsWith('.tsx') ? 'tsx' : 'ts',
872
+ }).program
873
+ const names = networkNames(program, file)
874
+ for (const statement of program.body) {
875
+ if (statement.type !== 'ExportNamedDeclaration') continue
876
+ if (statement.declaration?.type !== 'VariableDeclaration') continue
877
+ for (const declarator of statement.declaration.declarations) {
878
+ if (declarator.id.type === 'Identifier' && names.fetch.has(declarator.id.name))
879
+ aliases.add(declarator.id.name)
880
+ }
881
+ }
882
+ return aliases
883
+ }
884
+
885
+ function networkNames(source: Program, file: string): NetworkNames {
825
886
  const names: NetworkNames = {
826
887
  fetch: new Set(['fetch']),
827
888
  httpClient: new Set(['http', 'https']),
@@ -864,6 +925,16 @@ function networkNames(source: Program): NetworkNames {
864
925
  },
865
926
  ImportDeclaration(node) {
866
927
  const moduleName = node.source.value.replace(/^node:/, '')
928
+ const fetchAliases = exportedFetchAliases(file, node.source.value)
929
+ for (const specifier of node.specifiers) {
930
+ if (
931
+ specifier.type === 'ImportSpecifier' &&
932
+ specifier.imported.type === 'Identifier' &&
933
+ fetchAliases.has(specifier.imported.name)
934
+ ) {
935
+ names.fetch.add(specifier.local.name)
936
+ }
937
+ }
867
938
  if (moduleName === 'http' || moduleName === 'https') {
868
939
  for (const specifier of node.specifiers) {
869
940
  if (specifier.type === 'ImportSpecifier') {
@@ -895,7 +966,10 @@ function networkNames(source: Program): NetworkNames {
895
966
  },
896
967
  VariableDeclarator(node) {
897
968
  if (node.id.type !== 'Identifier' || !node.init) return
898
- if (node.init.type === 'Identifier' && names.fetch.has(node.init.name)) {
969
+ if (
970
+ (node.init.type === 'Identifier' && names.fetch.has(node.init.name)) ||
971
+ isFetchBinding(node.init)
972
+ ) {
899
973
  names.fetch.add(node.id.name)
900
974
  }
901
975
  if (isClientExpression(node.init, names.httpClient)) {
@@ -1007,7 +1081,7 @@ export function detectOutboundCalls(repositoryRoot: string): DetectedOutboundCal
1007
1081
  const program = parseSync(file, source, {
1008
1082
  lang: file.endsWith('.tsx') ? 'tsx' : 'ts',
1009
1083
  }).program
1010
- const names = networkNames(program)
1084
+ const names = networkNames(program, file)
1011
1085
  const relative = path.relative(repositoryRoot, file).split(path.sep).join('/')
1012
1086
  const recordCall = (node: CallExpression | NewExpression): void => {
1013
1087
  const kind = callKind(node, names, source)
package/detox/index.ts CHANGED
@@ -657,10 +657,19 @@ export async function waitForSootsimIdle(
657
657
  // find a node in the sootsim tree matching a matcher descriptor
658
658
  async function findNodeByMatcher(matcher: Matcher): Promise<any> {
659
659
  const page = getPage()
660
- if (matcher.index !== undefined || matcher.type === 'text') {
660
+ if (
661
+ matcher.regexFlags !== undefined ||
662
+ matcher.index !== undefined ||
663
+ matcher.type === 'text'
664
+ ) {
661
665
  return page.evaluate(async (indexedMatcher: Matcher) => {
662
- const filter =
663
- indexedMatcher.type === 'id'
666
+ const regex =
667
+ indexedMatcher.regexFlags !== undefined
668
+ ? new RegExp(indexedMatcher.value, indexedMatcher.regexFlags)
669
+ : null
670
+ const filter = regex
671
+ ? {}
672
+ : indexedMatcher.type === 'id'
664
673
  ? { hasId: indexedMatcher.value }
665
674
  : indexedMatcher.type === 'text'
666
675
  ? { hasText: indexedMatcher.value }
@@ -670,16 +679,18 @@ async function findNodeByMatcher(matcher: Matcher): Promise<any> {
670
679
  ? { hasRole: indexedMatcher.value }
671
680
  : { type: indexedMatcher.value }
672
681
  const results = await window.__sootsimTest!.queryAll(filter)
673
- // detox by.text matches exact text elements, with or without an index.
674
- // hasText also matches ancestors and substrings, which are not targets.
675
- const indexed =
676
- indexedMatcher.type === 'text'
677
- ? results.filter(
678
- (node) =>
679
- (node.type === 'text' || node.nodeId < 0) &&
680
- node.text === indexedMatcher.value,
681
- )
682
- : results
682
+ // text targets exclude ancestors; string values keep exact matching.
683
+ const indexed = results.filter((node) => {
684
+ if (indexedMatcher.type === 'text' && node.type !== 'text' && node.nodeId >= 0)
685
+ return false
686
+ if (!regex)
687
+ return indexedMatcher.type !== 'text' || node.text === indexedMatcher.value
688
+ const matches = (value: string | null) =>
689
+ typeof value === 'string' && regex.test(value)
690
+ if (indexedMatcher.type === 'id') return matches(node.testID) || matches(node.id)
691
+ if (indexedMatcher.type === 'text') return matches(node.text)
692
+ return matches(node.accessibilityLabel || (node.accessible ? node.text : null))
693
+ })
683
694
  return indexed[indexedMatcher.index ?? 0] ?? null
684
695
  }, matcher)
685
696
  }
@@ -1290,8 +1301,10 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1290
1301
  async replaceText(text: string) {
1291
1302
  const page = getPage()
1292
1303
  const node = await findNodeByMatcher(matcher)
1304
+ const resolvedId = node?.testID ?? node?.id
1293
1305
  if (
1294
1306
  matcher.type === 'id' &&
1307
+ typeof resolvedId === 'string' &&
1295
1308
  typeof node?.nodeId === 'number' &&
1296
1309
  node.nodeId >= 0x40000000
1297
1310
  ) {
@@ -1301,7 +1314,7 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1301
1314
  if (typeof mainShell?.callTestBridge !== 'function') return false
1302
1315
  return mainShell.callTestBridge('replaceTextByTestId', id, value)
1303
1316
  },
1304
- { id: matcher.value, value: text },
1317
+ { id: resolvedId, value: text },
1305
1318
  )
1306
1319
  if (replaced === true) return
1307
1320
  }
package/detox/matchers.ts CHANGED
@@ -4,20 +4,31 @@
4
4
  export interface Matcher {
5
5
  type: 'id' | 'text' | 'label' | 'type' | 'role'
6
6
  value: string
7
+ regexFlags?: string
7
8
  index?: number
8
9
  }
9
10
 
11
+ function createMatcher(type: 'id' | 'text' | 'label', value: string | RegExp): Matcher {
12
+ if (typeof value === 'string') return { type, value }
13
+ // detox ignores global and sticky state and always uses unicode matching.
14
+ return {
15
+ type,
16
+ value: value.source,
17
+ regexFlags: value.flags.replace(/[^ims]/g, '') + 'u',
18
+ }
19
+ }
20
+
10
21
  export const by = {
11
- id(testID: string): Matcher {
12
- return { type: 'id', value: testID }
22
+ id(testID: string | RegExp): Matcher {
23
+ return createMatcher('id', testID)
13
24
  },
14
25
 
15
- text(text: string): Matcher {
16
- return { type: 'text', value: text }
26
+ text(text: string | RegExp): Matcher {
27
+ return createMatcher('text', text)
17
28
  },
18
29
 
19
- label(accessibilityLabel: string): Matcher {
20
- return { type: 'label', value: accessibilityLabel }
30
+ label(accessibilityLabel: string | RegExp): Matcher {
31
+ return createMatcher('label', accessibilityLabel)
21
32
  },
22
33
 
23
34
  type(nativeType: string): Matcher {
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1477 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1481 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;