peachsim 0.1.1635 → 0.1.1640

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 (129) hide show
  1. package/README.md +5 -1
  2. package/cli/app-config.ts +14 -5
  3. package/cli/app-stubs.ts +62 -13
  4. package/cli/bridge-flow-runner.ts +2 -2
  5. package/cli/cloud-client.ts +60 -40
  6. package/cli/commands/doctor.ts +13 -5
  7. package/cli/commands/flow.ts +1 -1
  8. package/cli/commands/inspect/shared.ts +9 -4
  9. package/cli/commands/inspect.ts +65 -47
  10. package/cli/commands/preview.ts +1 -1
  11. package/cli/commands/record.ts +97 -47
  12. package/cli/commands/swift.ts +9 -3
  13. package/cli/commands/upload.ts +48 -42
  14. package/cli/drivers/playwright-sim-host.ts +65 -42
  15. package/cli/main.ts +1 -1
  16. package/cli/outbound-endpoints.ts +7 -5
  17. package/cli/pause-aware-timeout.ts +4 -0
  18. package/cli/ws-bridge.ts +19 -10
  19. package/detox/attempt-events.cjs +62 -5
  20. package/detox/attempt-events.d.cts +7 -0
  21. package/detox/expectations.ts +7 -3
  22. package/detox/index.ts +121 -47
  23. package/detox/motion-change.cjs +170 -21
  24. package/detox/motion-change.d.cts +32 -0
  25. package/detox/navigation-lifecycle.ts +35 -3
  26. package/dist-lib/agent-daemon-client.cjs +1 -1
  27. package/dist-lib/agent-events.cjs +1 -1
  28. package/dist-lib/attached-projects.cjs +1 -1
  29. package/dist-lib/auth/shared-session.cjs +1 -1
  30. package/dist-lib/backend-origin.cjs +8 -7
  31. package/dist-lib/beta.cjs +1 -1
  32. package/dist-lib/beta.mjs +1 -1
  33. package/dist-lib/bridge-constants.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.mjs +1 -1
  36. package/dist-lib/bridge-contract.cjs +1 -1
  37. package/dist-lib/bridge-contract.mjs +1 -1
  38. package/dist-lib/bundle-channel.cjs +4038 -1983
  39. package/dist-lib/capture-contract.cjs +1 -1
  40. package/dist-lib/capture-contract.mjs +1 -1
  41. package/dist-lib/cloud-contract.cjs +1 -1
  42. package/dist-lib/cloud-contract.mjs +1 -1
  43. package/dist-lib/cloud-test-args.cjs +1 -1
  44. package/dist-lib/cloud-test-args.mjs +1 -1
  45. package/dist-lib/cloud.cjs +1 -1
  46. package/dist-lib/cloud.mjs +1 -1
  47. package/dist-lib/config.cjs +1 -1
  48. package/dist-lib/config.mjs +1 -1
  49. package/dist-lib/detox/index.cjs +509 -86
  50. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  51. package/dist-lib/expo-swift-bundle.cjs +3551 -1574
  52. package/dist-lib/expo-swift-metadata.cjs +9 -7
  53. package/dist-lib/expo-swift-metadata.mjs +9 -7
  54. package/dist-lib/home-paths.cjs +1 -1
  55. package/dist-lib/host/bridge-host.cjs +43633 -2635
  56. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  57. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  58. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  59. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  60. package/dist-lib/host/websocket-proxy.cjs +1 -1
  61. package/dist-lib/index.cjs +2748 -128
  62. package/dist-lib/jump-to-source-babel.cjs +1 -1
  63. package/dist-lib/jump-to-source-native.cjs +1 -1
  64. package/dist-lib/kotlin-host.cjs +1 -1
  65. package/dist-lib/menu.cjs +1 -1
  66. package/dist-lib/menu.mjs +1 -1
  67. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  68. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  69. package/dist-lib/metro-production-bundle.cjs +1 -1
  70. package/dist-lib/metro-production-bundle.mjs +1 -1
  71. package/dist-lib/metro.cjs +2547 -83
  72. package/dist-lib/native-app-identity-project.cjs +237 -50
  73. package/dist-lib/native-app-identity-project.mjs +237 -50
  74. package/dist-lib/native-app-identity.cjs +29 -30
  75. package/dist-lib/native-app-identity.mjs +29 -30
  76. package/dist-lib/native-firebase-metadata.cjs +1 -1
  77. package/dist-lib/native-firebase-metadata.mjs +1 -1
  78. package/dist-lib/plist.cjs +1 -1
  79. package/dist-lib/plist.mjs +1 -1
  80. package/dist-lib/profiles.cjs +1 -1
  81. package/dist-lib/public-brand.cjs +1 -1
  82. package/dist-lib/react-native-host-modules.cjs +2 -2
  83. package/dist-lib/react-native-host-modules.mjs +2 -2
  84. package/dist-lib/scripts/dev-server-scanner.cjs +8 -6
  85. package/dist-lib/sdk.cjs +1 -1
  86. package/dist-lib/sdk.mjs +1 -1
  87. package/dist-lib/skills.cjs +65403 -22739
  88. package/dist-lib/swift-compile.cjs +93 -32
  89. package/dist-lib/swift-compile.mjs +93 -32
  90. package/dist-lib/swift-package.cjs +1 -1
  91. package/dist-lib/swift-package.mjs +1 -1
  92. package/dist-lib/swift-submit.cjs +7 -5
  93. package/dist-lib/swift-submit.mjs +7 -5
  94. package/dist-lib/swift-toolchain.cjs +316 -41
  95. package/dist-lib/swift.cjs +1 -1
  96. package/dist-lib/vite-swift.cjs +7335 -5432
  97. package/dist-lib/vite.cjs +2857 -12
  98. package/dist-lib/worklet-executable.cjs +14 -2
  99. package/dist-lib/worklet-executable.mjs +14 -2
  100. package/package.json +1 -1
  101. package/src/backend-origin.ts +13 -6
  102. package/src/expo-swift-host.ts +5 -2
  103. package/src/expoKotlinContract.ts +4 -7
  104. package/src/expoSwiftContract.ts +4 -6
  105. package/src/host/staged-host-files.ts +9 -0
  106. package/src/native-app-identity-project.ts +1 -4
  107. package/src/native-app-identity.ts +1 -4
  108. package/src/nativeAppIconContract.ts +1 -4
  109. package/src/output-lock-stdio.ts +36 -0
  110. package/src/react-native-bridge-modules.ts +21 -6
  111. package/src/react-native-host-modules.ts +4 -1
  112. package/src/settings-bundle.ts +1 -4
  113. package/src/swift-compile.ts +91 -33
  114. package/src/swift-distribution.lock.json +22 -12
  115. package/src/swift-distribution.ts +103 -4
  116. package/src/swift-host.ts +89 -26
  117. package/src/swift-icu.ts +16 -10
  118. package/src/swift-objc-compile.ts +355 -0
  119. package/src/swift-objc-sdk.ts +689 -18
  120. package/src/swift-objc-standin.ts +126 -14
  121. package/src/swift-package-manifest.ts +39 -1
  122. package/src/swift-packages.ts +35 -21
  123. package/src/swift-standin-emit.ts +40 -7
  124. package/src/swift-standins.ts +404 -138
  125. package/src/swift-submit.ts +3 -5
  126. package/src/swift-toolchain.ts +378 -46
  127. package/src/worker-system-chunks.ts +11 -12
  128. package/src/worklet-executable.ts +19 -1
  129. package/src/xcode-project.ts +270 -30
package/README.md CHANGED
@@ -599,7 +599,11 @@ foreground or background state. Both retain its browser session and app state.
599
599
  `sourceApp` does not add fields to React Native's `{ url }` event. A
600
600
  stopped guest rejects URL delivery. Explicit `newInstance: true`, deletion,
601
601
  and video recording create a new browser context; cold launches still use the
602
- supplied shell URL. When synchronization is enabled, URL delivery waits for
602
+ supplied shell URL. Process termination and `newInstance: true` preserve
603
+ cookies, local storage and IndexedDB for the next launch. `delete: true` and
604
+ driver cleanup clear that retained storage. Relaunch creates a fresh runtime,
605
+ so input focus and other in-memory component state do not survive termination.
606
+ When synchronization is enabled, URL delivery waits for
603
607
  existing work before dispatch and for navigation and painting afterward.
604
608
 
605
609
  Element taps finish viewport measurement before resolving the selected node's
package/cli/app-config.ts CHANGED
@@ -89,12 +89,21 @@ async function loadConfigFile(
89
89
  }
90
90
  const wire = WIRE_SLOTS.filter((slot) => loaded[slot] !== undefined)
91
91
  if (wire.length > 0) {
92
- const migration = wireSlotMigration(project.appDir, {
93
- modules: { ...loaded.turboModules, ...loaded.nativeModules },
94
- views: { ...loaded.nativeViews },
95
- })
92
+ const slots: readonly string[] = WIRE_SLOTS
93
+ const kept = Object.entries(loaded)
94
+ .filter(([key, value]) => !slots.includes(key) && value !== undefined)
95
+ .map(([key]) => key)
96
+ const migration = wireSlotMigration(
97
+ project.appDir,
98
+ project.configPath,
99
+ {
100
+ modules: { ...loaded.turboModules, ...loaded.nativeModules },
101
+ views: { ...loaded.nativeViews },
102
+ },
103
+ kept,
104
+ )
96
105
  throw new Error(
97
- `${wire.join(', ')} moved under packages, keyed by the package whose native side they answer. replace them with:\n${migration}`,
106
+ `${wire.join(', ')} moved under packages, keyed by the package whose native side they answer. ${kept.length ? 'remove them and add' : 'replace the config with'}:\n${migration}`,
98
107
  )
99
108
  }
100
109
  return resolveConfigReplacementFiles(loaded, project.configPath)
package/cli/app-stubs.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  type PeachConfig,
26
26
  } from '../src/config'
27
27
  import { isRecord } from '../src/is-record'
28
+ import { rnxPublicBrand } from '../src/public-brand'
28
29
  import { visitParsedProgram } from '../src/visit-parsed-program'
29
30
  import {
30
31
  readProjectManifest,
@@ -380,26 +381,61 @@ export function packageNativeRequests(dir: string): NativeRequest[] {
380
381
  )
381
382
  }
382
383
 
383
- // the `packages` block that replaces a config's old top-level wire slots: each
384
- // name keyed by the direct dependency whose JavaScript asks for it. `packages`
385
- // takes only 'noop' or a file path, so any other old value is named for a hand
386
- // move instead of guessed.
387
- export function wireSlotMigration(
384
+ // the native names each installed dependency's own JavaScript asks for, keyed
385
+ // `kind:name`. links to source outside node_modules are the app's own packages
386
+ // and are scanned as app code instead.
387
+ function dependencyNativeOwners(
388
388
  appDir: string,
389
- wire: { modules: Record<string, unknown>; views: Record<string, unknown> },
390
- ): string {
389
+ manifest: ProjectManifest | null,
390
+ ): Map<string, string> {
391
391
  const owners = new Map<string, string>()
392
- const manifest = readProjectManifest(appDir)
393
392
  for (const name of Object.keys({
394
393
  ...manifest?.devDependencies,
395
394
  ...manifest?.dependencies,
396
395
  })) {
397
396
  const dir = installedPackageDir(appDir, name)
398
- if (!dir) continue
397
+ if (!dir || !dir.split('/').includes('node_modules')) continue
399
398
  for (const request of packageNativeRequests(dir))
400
399
  if (!owners.has(`${request.kind}:${request.name}`))
401
400
  owners.set(`${request.kind}:${request.name}`, name)
402
401
  }
402
+ return owners
403
+ }
404
+
405
+ // a complete config module in the form the app's config file already uses, so
406
+ // the printed text pastes over it
407
+ function configModule(configPath: string | null, packages: string[]): string {
408
+ const body = [' packages: {', ...packages, ' },']
409
+ if (
410
+ configPath &&
411
+ (configPath.endsWith('.cjs') ||
412
+ (configPath.endsWith('.js') &&
413
+ /\bmodule\.exports\b/.test(readSmallFile(configPath) ?? '')))
414
+ )
415
+ return ['module.exports = {', ...body, '}'].join('\n')
416
+ return [
417
+ `import { defineConfig } from '${rnxPublicBrand.packageName}/config'`,
418
+ '',
419
+ 'export default defineConfig({',
420
+ ...body,
421
+ '})',
422
+ ].join('\n')
423
+ }
424
+
425
+ // what replaces a config's old top-level wire slots: each name keyed by the
426
+ // package whose JavaScript asks for it. a config holding only those slots gets
427
+ // a complete module; one with other settings (`kept`) gets just the packages
428
+ // entries, since a whole module would drop them. `packages` takes only 'noop'
429
+ // or a file path, so any other old value is named for a hand move instead of
430
+ // guessed.
431
+ export function wireSlotMigration(
432
+ appDir: string,
433
+ configPath: string | null,
434
+ wire: { modules: Record<string, unknown>; views: Record<string, unknown> },
435
+ kept: readonly string[] = [],
436
+ ): string {
437
+ const manifest = readProjectManifest(appDir)
438
+ const owners = dependencyNativeOwners(appDir, manifest)
403
439
  if (manifest)
404
440
  for (const request of scanLocalNative(
405
441
  appDir,
@@ -408,6 +444,7 @@ export function wireSlotMigration(
408
444
  undefined,
409
445
  new Set(),
410
446
  true,
447
+ owners,
411
448
  ))
412
449
  if (!owners.has(`${request.kind}:${request.name}`))
413
450
  owners.set(`${request.kind}:${request.name}`, request.package)
@@ -453,9 +490,17 @@ export function wireSlotMigration(
453
490
  ].filter(Boolean)
454
491
  return ` [${JSON.stringify(owner)}]: { ${parts.join(', ')} },`
455
492
  })
493
+ if (lines.length && kept.length)
494
+ return [
495
+ ...manual.map((line) => `// ${line}`),
496
+ `// keep ${kept.join(', ')}; merge these into packages`,
497
+ ' packages: {',
498
+ ...lines,
499
+ ' },',
500
+ ].join('\n')
456
501
  return [
457
- ...(lines.length ? [' packages: {', ...lines, ' },'] : []),
458
- ...manual.map((line) => ` // ${line}`),
502
+ ...manual.map((line) => `// ${line}`),
503
+ ...(lines.length ? [configModule(configPath, lines)] : []),
459
504
  ].join('\n')
460
505
  }
461
506
 
@@ -519,6 +564,7 @@ export function scanLocalNative(
519
564
  platform: ScanResult['scoreSummary']['platform'] | undefined,
520
565
  knownPackages: ReadonlySet<string> = new Set(),
521
566
  includeLegacy = false,
567
+ providers?: ReadonlyMap<string, string>,
522
568
  ): LocalNative[] {
523
569
  const expoModuleDirs: string[] = []
524
570
  const excludedModuleDirs: string[] = []
@@ -571,11 +617,14 @@ export function scanLocalNative(
571
617
  return result
572
618
  }
573
619
  const found = new Map<string, LocalNative>()
620
+ // a name an installed dependency asks for belongs to that dependency, whoever
621
+ // in the app spells it out
574
622
  const add = (kind: LocalNative['kind'], name: string, file: string, line: number) => {
575
- const packageName = owner(dirname(file))
576
- if (knownPackages.has(packageName)) return
577
623
  const key = `${kind}:${name}`
578
624
  if (found.has(key)) return
625
+ providers ??= dependencyNativeOwners(appDir, pkg)
626
+ const packageName = providers.get(key) ?? owner(dirname(file))
627
+ if (knownPackages.has(packageName)) return
579
628
  const moduleDir = expoModuleDirs.find((dir) => file.startsWith(`${dir}/`)) ?? null
580
629
  found.set(key, {
581
630
  kind,
@@ -3103,11 +3103,11 @@ export class SootSimBridgeFlowRunner {
3103
3103
  }
3104
3104
 
3105
3105
  async startProfile() {
3106
- const result = await this.evaluate<any>(`(() => {
3106
+ const result = await this.evaluate<any>(`(async () => {
3107
3107
  if (!window.__sootsimShellPerf) {
3108
3108
  return { error: "shell frame profile unavailable (__sootsimShellPerf missing on the page)" }
3109
3109
  }
3110
- window.__sootsimShellPerf.start()
3110
+ await window.__sootsimShellPerf.start()
3111
3111
  return { started: true }
3112
3112
  })()`)
3113
3113
  if (result?.error) {
@@ -27,6 +27,10 @@ import {
27
27
  import { rewriteGuestImportMeta } from '../../peach-engine/src/guest-import-meta.ts'
28
28
  import { loadMetroFingerprintRegistry } from '../../peach-engine/src/metro-fingerprint-registry-client.ts'
29
29
  import { inferMetroModuleIdentity } from '../../peach-engine/src/metro-fingerprint.ts'
30
+ import {
31
+ isNativeJavaScriptBundle,
32
+ isRolldownBundle,
33
+ } from '../../peach-engine/src/preview/bundle-layout'
30
34
  import { isLoopbackHost } from '../src/backend-origin.ts'
31
35
  import {
32
36
  isRnxCloudCommandType,
@@ -172,7 +176,7 @@ function resolveCloudOrigin(value?: string): string {
172
176
  function readImmutableBundleSource(bundlePath: string, command: string): string {
173
177
  const filepath = resolve(bundlePath)
174
178
  if (extname(filepath) !== '.js') {
175
- throw new Error(`${command} requires one local Metro .js bundle`)
179
+ throw new Error(`${command} requires one local .js bundle`)
176
180
  }
177
181
  const descriptor = openSync(filepath, 'r')
178
182
  try {
@@ -200,9 +204,6 @@ function readImmutableBundleSource(bundlePath: string, command: string): string
200
204
  } catch {
201
205
  throw new Error(`${command} bundle must be valid UTF-8 JavaScript`)
202
206
  }
203
- if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) {
204
- throw new Error(`${command} requires a Metro JavaScript bundle`)
205
- }
206
207
  return source
207
208
  } finally {
208
209
  closeSync(descriptor)
@@ -231,9 +232,6 @@ async function fetchBundleSource(bundleUrl: string, command: string): Promise<st
231
232
  if (text.length === 0) {
232
233
  throw new Error(`${command} bundle is empty`)
233
234
  }
234
- if (text.includes('\0') || !text.includes('__d(') || !text.includes('__r(')) {
235
- throw new Error(`${command} requires a Metro JavaScript bundle`)
236
- }
237
235
  return text
238
236
  }
239
237
 
@@ -260,6 +258,9 @@ async function loadBundleSource(bundleInput: string, command: string): Promise<s
260
258
  const source = isRemoteBundleInput(bundleInput)
261
259
  ? await fetchBundleSource(bundleInput, command)
262
260
  : readImmutableBundleSource(bundleInput, command)
261
+ if (!isNativeJavaScriptBundle(source)) {
262
+ throw new Error(`${command} requires a supported React Native JavaScript bundle`)
263
+ }
263
264
  // validate the actual runtime script form without executing guest code or
264
265
  // allocating a host.
265
266
  try {
@@ -728,25 +729,44 @@ export async function produceCloudArtifact(
728
729
  maxBytes: number = MAX_RNX_CLOUD_BUNDLE_BYTES,
729
730
  assetsDirectory?: string,
730
731
  ): Promise<CloudArtifactProduction> {
731
- return produceArtifact(
732
+ const prepared = await embedBundleAssets(
732
733
  bundlePath,
733
- maxBytes,
734
734
  REMOTE_COMMAND,
735
735
  assetsDirectory ? 'require-assets' : 'allow-placeholders',
736
736
  assetsDirectory,
737
737
  )
738
+ return produceArtifact(prepared, maxBytes, REMOTE_COMMAND)
738
739
  }
739
740
 
740
741
  /** prepares a standalone preview whose bytes must represent every Metro asset. */
741
- export async function producePreviewArtifact(
742
- bundlePath: string,
743
- ): Promise<CloudArtifactProduction> {
744
- return produceArtifact(
745
- bundlePath,
746
- Number.POSITIVE_INFINITY,
747
- PREVIEW_COMMAND,
748
- 'require-assets',
749
- )
742
+ export async function producePreviewArtifact(bundlePath: string) {
743
+ const prepared = await embedBundleAssets(bundlePath, PREVIEW_COMMAND, 'require-assets')
744
+ if (!isRolldownBundle(prepared.source)) {
745
+ return {
746
+ ...(await produceArtifact(prepared, Number.POSITIVE_INFINITY, PREVIEW_COMMAND)),
747
+ minified: true,
748
+ }
749
+ }
750
+ const bytes = Buffer.from(prepared.code, 'utf8')
751
+ const sha256 = createHash('sha256').update(bytes).digest('hex')
752
+ return {
753
+ bytes,
754
+ sha256,
755
+ minified: false,
756
+ receipt: {
757
+ source: {
758
+ sha256: createHash('sha256').update(prepared.source).digest('hex'),
759
+ bytes: Buffer.byteLength(prepared.source, 'utf8'),
760
+ },
761
+ artifact: { sha256, bytes: bytes.byteLength },
762
+ },
763
+ warnings:
764
+ prepared.search.unconfirmed && prepared.dest
765
+ ? [
766
+ `peach preview could not authenticate asset hashes in ${prepared.dest.directory}; confirm these are the assets this bundle was built against.`,
767
+ ]
768
+ : [],
769
+ }
750
770
  }
751
771
 
752
772
  export interface CloudBoxArtifactProduction {
@@ -890,18 +910,10 @@ async function embedBundleAssets(
890
910
  }
891
911
 
892
912
  async function produceArtifact(
893
- bundlePath: string,
913
+ prepared: Awaited<ReturnType<typeof embedBundleAssets>>,
894
914
  maxBytes: number,
895
915
  command: string,
896
- assetPolicy: 'allow-placeholders' | 'require-assets',
897
- assetsDirectory?: string,
898
916
  ): Promise<CloudArtifactProduction> {
899
- const prepared = await embedBundleAssets(
900
- bundlePath,
901
- command,
902
- assetPolicy,
903
- assetsDirectory,
904
- )
905
917
  const artifact = await buildRnxCloudArtifact(prepared.code, {
906
918
  inferModuleIdentity: (source) => inferCloudModuleIdentity(source, command),
907
919
  })
@@ -1453,12 +1465,17 @@ function cloudCommand(command: {
1453
1465
  class CloudBridge implements WsBridge {
1454
1466
  readonly plane = 'cloud' as const
1455
1467
 
1456
- constructor(private readonly session: CloudSimSession) {}
1468
+ constructor(
1469
+ private readonly session: CloudSimSession,
1470
+ private readonly explicitTimeoutMs?: number,
1471
+ ) {}
1457
1472
 
1458
1473
  async send(
1459
1474
  command: { type: string; [key: string]: unknown },
1460
- options?: { timeoutMs?: number },
1475
+ options?: { timeoutMs?: number; defaultTimeoutMs?: number },
1461
1476
  ): Promise<unknown> {
1477
+ const timeoutMs =
1478
+ options?.timeoutMs ?? this.explicitTimeoutMs ?? options?.defaultTimeoutMs
1462
1479
  const body: CloudCommandEnvelope = {
1463
1480
  id: randomUUID(),
1464
1481
  claimId: this.session.claimId,
@@ -1475,9 +1492,7 @@ class CloudBridge implements WsBridge {
1475
1492
  'content-type': 'application/json',
1476
1493
  },
1477
1494
  body: JSON.stringify(body),
1478
- ...(options?.timeoutMs
1479
- ? { signal: AbortSignal.timeout(options.timeoutMs) }
1480
- : {}),
1495
+ ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
1481
1496
  },
1482
1497
  )
1483
1498
  } catch (error) {
@@ -1538,12 +1553,17 @@ class CloudBridge implements WsBridge {
1538
1553
  class CloudBoxBridge implements WsBridge {
1539
1554
  readonly plane = 'cloud' as const
1540
1555
 
1541
- constructor(private readonly session: CloudBoxSession) {}
1556
+ constructor(
1557
+ private readonly session: CloudBoxSession,
1558
+ private readonly explicitTimeoutMs?: number,
1559
+ ) {}
1542
1560
 
1543
1561
  async send(
1544
1562
  command: { type: string; [key: string]: unknown },
1545
- options?: { timeoutMs?: number },
1563
+ options?: { timeoutMs?: number; defaultTimeoutMs?: number },
1546
1564
  ): Promise<unknown> {
1565
+ const timeoutMs =
1566
+ options?.timeoutMs ?? this.explicitTimeoutMs ?? options?.defaultTimeoutMs
1547
1567
  const id = randomUUID()
1548
1568
  // the wire id is this request's, so the command carries none. `simId` names
1549
1569
  // a local sim among several and a box has exactly one.
@@ -1562,9 +1582,7 @@ class CloudBoxBridge implements WsBridge {
1562
1582
  'content-type': 'application/json',
1563
1583
  },
1564
1584
  body: JSON.stringify({ id, command: payload }),
1565
- ...(options?.timeoutMs
1566
- ? { signal: AbortSignal.timeout(options.timeoutMs) }
1567
- : {}),
1585
+ ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
1568
1586
  },
1569
1587
  )
1570
1588
  } catch (error) {
@@ -1619,13 +1637,15 @@ class CloudBoxBridge implements WsBridge {
1619
1637
  }
1620
1638
 
1621
1639
  export function createCloudBridgeForParsed(
1622
- parsed: Pick<ParsedBridgeCliArgs, 'simId' | 'simIdSource'>,
1640
+ parsed: Pick<ParsedBridgeCliArgs, 'simId' | 'simIdSource'> &
1641
+ Partial<Pick<ParsedBridgeCliArgs, 'explicitTimeout' | 'commandTimeoutMs'>>,
1623
1642
  ): WsBridge | null {
1624
1643
  const session = resolveCloudSessionForParsed(parsed)
1625
1644
  if (!session) return null
1645
+ const timeoutMs = parsed.explicitTimeout ? parsed.commandTimeoutMs : undefined
1626
1646
  return session.service === 'box'
1627
- ? new CloudBoxBridge(session)
1628
- : new CloudBridge(session)
1647
+ ? new CloudBoxBridge(session, timeoutMs)
1648
+ : new CloudBridge(session, timeoutMs)
1629
1649
  }
1630
1650
 
1631
1651
  // deletes the box, which ends the simulator it holds. a cloud session box is
@@ -476,7 +476,10 @@ function packageRow(
476
476
  return {
477
477
  rank: RANK.unknown,
478
478
  glyph: accent('?'),
479
- detail: 'native code Peach does not know; stub it below',
479
+ // fonts and assets, or names built at runtime, leave nothing to stub by name
480
+ detail: unknownNative.requests.length
481
+ ? 'native code Peach does not know; stub it below'
482
+ : 'native code; names no native module',
480
483
  tone: muted,
481
484
  }
482
485
  // a stub answers ahead of Peach's own support for the package
@@ -560,19 +563,24 @@ function printNativeGuidance(
560
563
  )
561
564
  }
562
565
  // the open names grouped by the package they belong to, the key `packages` takes
563
- const open = new Map<string, NativeRequest[]>()
566
+ const open = new Map<string, Map<string, NativeRequest>>()
564
567
  for (const [owner, requests] of [
565
568
  ...unknownNative.map((item) => [item.name, item.requests] as const),
566
569
  ...local.map((item) => [item.package, [item]] as const),
567
570
  ]) {
568
- const unanswered = requests.filter((request) => !request.answered)
569
- if (unanswered.length) open.set(owner, [...(open.get(owner) ?? []), ...unanswered])
571
+ for (const request of requests) {
572
+ if (request.answered) continue
573
+ const names = open.get(owner) ?? new Map<string, NativeRequest>()
574
+ names.set(`${request.kind}:${request.name}`, request)
575
+ open.set(owner, names)
576
+ }
570
577
  }
571
578
  if (open.size) {
572
579
  console.log(`\n stub them in peach.config.ts, by package:\n`)
573
580
  console.log(muted(` export default defineConfig({`))
574
581
  console.log(muted(` packages: {`))
575
- for (const [owner, requests] of open) {
582
+ for (const [owner, requestsByName] of open) {
583
+ const requests = [...requestsByName.values()]
576
584
  console.log(muted(` '${owner}': {`))
577
585
  for (const kind of ['module', 'view'] as const) {
578
586
  const names = requests.filter((request) => request.kind === kind)
@@ -973,7 +973,7 @@ flow extension:
973
973
  // canvas during scrub for smooth motion while the real app
974
974
  // catches up.
975
975
  if (videoPath) uploadArgs.push('--video', videoPath)
976
- if (scenePath) uploadArgs.push('--scene', scenePath)
976
+ if (scenePath) uploadArgs.push('--scene', scenePath, '--live-bundle')
977
977
  if (previewScreenshotPath) {
978
978
  uploadArgs.push('--screenshot', previewScreenshotPath)
979
979
  }
@@ -92,10 +92,15 @@ export async function maybeWaitForStartedScreenTransitions(
92
92
  ) {
93
93
  if (bridge.plane === 'cloud') return
94
94
  try {
95
- const result = await callTestBridge<SootSimScreenTransitionWaitResult>(
96
- bridge,
97
- 'waitForScreenTransitions',
98
- SCREEN_TRANSITION_PREFLIGHT,
95
+ // the sim bounds this wait itself, so the round trip gets a short budget of
96
+ // its own; under the command budget a wedged sim cost every read twice
97
+ const result: SootSimScreenTransitionWaitResult | undefined = await bridge.send(
98
+ {
99
+ type: 'call',
100
+ path: '__sootsimTest.waitForScreenTransitions',
101
+ args: [SCREEN_TRANSITION_PREFLIGHT],
102
+ },
103
+ { timeoutMs: SCREEN_TRANSITION_PREFLIGHT.timeoutMs + 1200 },
99
104
  )
100
105
  if (!opts.verbose || !result?.started) return
101
106
  if (result.timedOut) {