peachsim 0.1.1635 → 0.1.1641

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 (142) hide show
  1. package/README.md +10 -1
  2. package/cli/app-config.ts +14 -5
  3. package/cli/app-stubs.ts +96 -22
  4. package/cli/bridge-flow-runner.ts +2 -2
  5. package/cli/cloud-client.ts +60 -40
  6. package/cli/cloud-session.ts +16 -0
  7. package/cli/commands/assert.ts +17 -4
  8. package/cli/commands/box.ts +33 -19
  9. package/cli/commands/doctor.ts +37 -20
  10. package/cli/commands/flow.ts +1 -1
  11. package/cli/commands/inspect/actions.ts +3 -2
  12. package/cli/commands/inspect/core.ts +0 -8
  13. package/cli/commands/inspect/shared.ts +9 -4
  14. package/cli/commands/inspect.ts +65 -61
  15. package/cli/commands/platform.ts +2 -1
  16. package/cli/commands/preview.ts +1 -1
  17. package/cli/commands/record.ts +154 -68
  18. package/cli/commands/swift.ts +18 -4
  19. package/cli/commands/three-mode.ts +4 -38
  20. package/cli/commands/upload.ts +55 -49
  21. package/cli/drivers/playwright-sim-host.ts +65 -42
  22. package/cli/flow-live-status.ts +0 -2
  23. package/cli/main.ts +2 -2
  24. package/cli/outbound-endpoints.ts +7 -5
  25. package/cli/pause-aware-timeout.ts +4 -0
  26. package/cli/ws-bridge.ts +19 -16
  27. package/detox/attempt-events.cjs +99 -6
  28. package/detox/attempt-events.d.cts +7 -0
  29. package/detox/expectations.ts +7 -3
  30. package/detox/index.ts +259 -139
  31. package/detox/jest-environment.cjs +5 -1
  32. package/detox/motion-change.cjs +170 -21
  33. package/detox/motion-change.d.cts +32 -0
  34. package/detox/navigation-lifecycle.ts +35 -3
  35. package/dist-lib/agent-daemon-client.cjs +1 -1
  36. package/dist-lib/agent-events.cjs +1 -1
  37. package/dist-lib/attached-projects.cjs +1 -1
  38. package/dist-lib/auth/shared-session.cjs +1 -1
  39. package/dist-lib/backend-origin.cjs +17 -7
  40. package/dist-lib/beta.cjs +1 -1
  41. package/dist-lib/beta.mjs +1 -1
  42. package/dist-lib/bridge-constants.cjs +1 -1
  43. package/dist-lib/bridge-contract-input.cjs +1 -1
  44. package/dist-lib/bridge-contract-input.mjs +1 -1
  45. package/dist-lib/bridge-contract.cjs +1 -1
  46. package/dist-lib/bridge-contract.mjs +1 -1
  47. package/dist-lib/bundle-channel.cjs +5014 -2268
  48. package/dist-lib/capture-contract.cjs +1 -1
  49. package/dist-lib/capture-contract.mjs +1 -1
  50. package/dist-lib/cloud-contract.cjs +1 -1
  51. package/dist-lib/cloud-contract.mjs +1 -1
  52. package/dist-lib/cloud-test-args.cjs +1 -1
  53. package/dist-lib/cloud-test-args.mjs +1 -1
  54. package/dist-lib/cloud.cjs +1 -1
  55. package/dist-lib/cloud.mjs +1 -1
  56. package/dist-lib/config.cjs +1 -1
  57. package/dist-lib/config.mjs +1 -1
  58. package/dist-lib/detox/index.cjs +1036 -166
  59. package/dist-lib/detox/jest-environment.cjs +5 -1
  60. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  61. package/dist-lib/expo-swift-bundle.cjs +4613 -1963
  62. package/dist-lib/expo-swift-metadata.cjs +9 -7
  63. package/dist-lib/expo-swift-metadata.mjs +9 -7
  64. package/dist-lib/home-paths.cjs +1 -1
  65. package/dist-lib/host/bridge-host.cjs +58072 -16400
  66. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  67. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  68. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  69. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  70. package/dist-lib/host/websocket-proxy.cjs +1 -1
  71. package/dist-lib/index.cjs +2790 -143
  72. package/dist-lib/jump-to-source-babel.cjs +1 -1
  73. package/dist-lib/jump-to-source-native.cjs +1 -1
  74. package/dist-lib/kotlin-host.cjs +1 -1
  75. package/dist-lib/menu.cjs +1 -1
  76. package/dist-lib/menu.mjs +1 -1
  77. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  78. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  79. package/dist-lib/metro-production-bundle.cjs +1 -1
  80. package/dist-lib/metro-production-bundle.mjs +1 -1
  81. package/dist-lib/metro.cjs +2579 -95
  82. package/dist-lib/native-app-identity-project.cjs +243 -53
  83. package/dist-lib/native-app-identity-project.mjs +243 -53
  84. package/dist-lib/native-app-identity.cjs +29 -30
  85. package/dist-lib/native-app-identity.mjs +29 -30
  86. package/dist-lib/native-firebase-metadata.cjs +1 -1
  87. package/dist-lib/native-firebase-metadata.mjs +1 -1
  88. package/dist-lib/plist.cjs +1 -1
  89. package/dist-lib/plist.mjs +1 -1
  90. package/dist-lib/profiles.cjs +1 -1
  91. package/dist-lib/public-brand.cjs +1 -1
  92. package/dist-lib/react-native-host-modules.cjs +2 -2
  93. package/dist-lib/react-native-host-modules.mjs +2 -2
  94. package/dist-lib/scripts/dev-server-scanner.cjs +8 -6
  95. package/dist-lib/sdk.cjs +4 -3
  96. package/dist-lib/sdk.mjs +4 -3
  97. package/dist-lib/skills.cjs +66475 -23097
  98. package/dist-lib/swift-compile.cjs +452 -260
  99. package/dist-lib/swift-compile.mjs +451 -252
  100. package/dist-lib/swift-package.cjs +1 -1
  101. package/dist-lib/swift-package.mjs +1 -1
  102. package/dist-lib/swift-submit.cjs +14 -7
  103. package/dist-lib/swift-submit.mjs +14 -7
  104. package/dist-lib/swift-toolchain.cjs +316 -41
  105. package/dist-lib/swift.cjs +1 -1
  106. package/dist-lib/vite-swift.cjs +8188 -5636
  107. package/dist-lib/vite.cjs +2857 -12
  108. package/dist-lib/worklet-executable.cjs +14 -2
  109. package/dist-lib/worklet-executable.mjs +14 -2
  110. package/package.json +1 -1
  111. package/src/backend-origin.ts +21 -6
  112. package/src/expo-swift-host.ts +5 -2
  113. package/src/expoKotlinContract.ts +4 -7
  114. package/src/expoSwiftContract.ts +4 -6
  115. package/src/host/staged-host-files.ts +9 -0
  116. package/src/native-app-identity-project.ts +1 -4
  117. package/src/native-app-identity.ts +1 -4
  118. package/src/nativeAppIconContract.ts +1 -4
  119. package/src/output-lock-stdio.ts +36 -0
  120. package/src/react-native-bridge-modules.ts +106 -78
  121. package/src/react-native-host-modules.ts +4 -1
  122. package/src/settings-bundle.ts +1 -4
  123. package/src/swift-compile.ts +224 -139
  124. package/src/swift-distribution.lock.json +23 -12
  125. package/src/swift-distribution.ts +103 -4
  126. package/src/swift-host.ts +95 -30
  127. package/src/swift-icu.ts +16 -10
  128. package/src/swift-objc-compile.ts +547 -0
  129. package/src/swift-objc-metadata.ts +204 -0
  130. package/src/swift-objc-sdk.ts +883 -19
  131. package/src/swift-objc-standin.ts +126 -14
  132. package/src/swift-package-manifest.ts +42 -1
  133. package/src/swift-packages.ts +35 -21
  134. package/src/swift-root-lock.ts +153 -0
  135. package/src/swift-standin-emit.ts +40 -7
  136. package/src/swift-standins.ts +404 -138
  137. package/src/swift-submit.ts +18 -7
  138. package/src/swift-toolchain.ts +378 -46
  139. package/src/swiftContract.ts +12 -0
  140. package/src/worker-system-chunks.ts +11 -12
  141. package/src/worklet-executable.ts +19 -1
  142. package/src/xcode-project.ts +280 -33
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
@@ -652,6 +656,11 @@ peach screenshot --with-frame --output /tmp/peach-framed.png
652
656
  peach record --duration 5 --output /tmp/peach-anim.mp4
653
657
  ```
654
658
 
659
+ `peach record status` queries the recorder on the tracked simulator. A video
660
+ whose stop reply timed out remains available: status reports processing or
661
+ ready to save, and another `peach record stop --output <path>` joins the same
662
+ finalization and saves its bytes. `peach record cancel` discards them.
663
+
655
664
  `peach screenshot --with-frame` composes the real Peach shell device chrome
656
665
  around the raw screen bitmap (reusing the shell bezel/button geometry, excluding
657
666
  the Electron top bar, rail gutter, and other window chrome). Flows can request
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,77 @@ 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
+ // resolve the installed runtime dependency graph, including transitive native
385
+ // packages. workspace source is scanned as app code instead. a transitive
386
+ // package is scanned only when it ships native code: the graph runs to
387
+ // thousands of packages, and the rest name nothing a direct dependency does not.
388
+ function dependencyNativeOwners(
388
389
  appDir: string,
389
- wire: { modules: Record<string, unknown>; views: Record<string, unknown> },
390
- ): string {
390
+ manifest: ProjectManifest | null,
391
+ ): Map<string, string> {
391
392
  const owners = new Map<string, string>()
392
- const manifest = readProjectManifest(appDir)
393
- for (const name of Object.keys({
393
+ const pending = Object.keys({
394
394
  ...manifest?.devDependencies,
395
395
  ...manifest?.dependencies,
396
- })) {
397
- const dir = installedPackageDir(appDir, name)
398
- if (!dir) continue
399
- for (const request of packageNativeRequests(dir))
400
- if (!owners.has(`${request.kind}:${request.name}`))
401
- owners.set(`${request.kind}:${request.name}`, name)
396
+ }).map((name) => ({ from: appDir, name }))
397
+ const visited = new Set<string>()
398
+ for (let index = 0; index < pending.length; index++) {
399
+ const { from, name } = pending[index]
400
+ const dir = installedPackageDir(from, name)
401
+ if (!dir || visited.has(dir)) continue
402
+ visited.add(dir)
403
+ const dependency = readProjectManifest(dir)
404
+ const optional = readPackageJson(dir)?.optionalDependencies
405
+ if (
406
+ dir.split('/').includes('node_modules') &&
407
+ (from === appDir || hasNativeCode(dir, 'ios'))
408
+ )
409
+ for (const request of packageNativeRequests(dir))
410
+ if (!owners.has(`${request.kind}:${request.name}`))
411
+ owners.set(`${request.kind}:${request.name}`, dependency?.name ?? name)
412
+ for (const child of Object.keys({
413
+ ...dependency?.dependencies,
414
+ ...(isRecord(optional) ? optional : {}),
415
+ }))
416
+ pending.push({ from: dir, name: child })
402
417
  }
418
+ return owners
419
+ }
420
+
421
+ // a complete config module in the form the app's config file already uses, so
422
+ // the printed text pastes over it
423
+ function configModule(configPath: string | null, packages: string[]): string {
424
+ const body = [' packages: {', ...packages, ' },']
425
+ if (
426
+ configPath &&
427
+ (configPath.endsWith('.cjs') ||
428
+ (configPath.endsWith('.js') &&
429
+ /\bmodule\.exports\b/.test(readSmallFile(configPath) ?? '')))
430
+ )
431
+ return ['module.exports = {', ...body, '}'].join('\n')
432
+ return [
433
+ `import { defineConfig } from '${rnxPublicBrand.packageName}/config'`,
434
+ '',
435
+ 'export default defineConfig({',
436
+ ...body,
437
+ '})',
438
+ ].join('\n')
439
+ }
440
+
441
+ // what replaces a config's old top-level wire slots: each name keyed by the
442
+ // package whose JavaScript asks for it. a config holding only those slots gets
443
+ // a complete module; one with other settings (`kept`) gets just the packages
444
+ // entries, since a whole module would drop them. `packages` takes only 'noop'
445
+ // or a file path, so any other old value is named for a hand move instead of
446
+ // guessed.
447
+ export function wireSlotMigration(
448
+ appDir: string,
449
+ configPath: string | null,
450
+ wire: { modules: Record<string, unknown>; views: Record<string, unknown> },
451
+ kept: readonly string[] = [],
452
+ ): string {
453
+ const manifest = readProjectManifest(appDir)
454
+ const owners = dependencyNativeOwners(appDir, manifest)
403
455
  if (manifest)
404
456
  for (const request of scanLocalNative(
405
457
  appDir,
@@ -408,6 +460,7 @@ export function wireSlotMigration(
408
460
  undefined,
409
461
  new Set(),
410
462
  true,
463
+ owners,
411
464
  ))
412
465
  if (!owners.has(`${request.kind}:${request.name}`))
413
466
  owners.set(`${request.kind}:${request.name}`, request.package)
@@ -453,9 +506,17 @@ export function wireSlotMigration(
453
506
  ].filter(Boolean)
454
507
  return ` [${JSON.stringify(owner)}]: { ${parts.join(', ')} },`
455
508
  })
509
+ if (lines.length && kept.length)
510
+ return [
511
+ ...manual.map((line) => `// ${line}`),
512
+ `// keep ${kept.join(', ')}; merge these into packages`,
513
+ ' packages: {',
514
+ ...lines,
515
+ ' },',
516
+ ].join('\n')
456
517
  return [
457
- ...(lines.length ? [' packages: {', ...lines, ' },'] : []),
458
- ...manual.map((line) => ` // ${line}`),
518
+ ...manual.map((line) => `// ${line}`),
519
+ ...(lines.length ? [configModule(configPath, lines)] : []),
459
520
  ].join('\n')
460
521
  }
461
522
 
@@ -519,6 +580,7 @@ export function scanLocalNative(
519
580
  platform: ScanResult['scoreSummary']['platform'] | undefined,
520
581
  knownPackages: ReadonlySet<string> = new Set(),
521
582
  includeLegacy = false,
583
+ providers?: ReadonlyMap<string, string>,
522
584
  ): LocalNative[] {
523
585
  const expoModuleDirs: string[] = []
524
586
  const excludedModuleDirs: string[] = []
@@ -549,7 +611,12 @@ export function scanLocalNative(
549
611
  dir,
550
612
  scanRepository(dir).directories.flatMap((sub) =>
551
613
  listDir(sub)
552
- .filter((name) => NATIVE_SOURCE_FILE.test(name))
614
+ .filter(
615
+ (name) =>
616
+ NATIVE_SOURCE_FILE.test(name) &&
617
+ (!platform ||
618
+ (platform === 'ios' ? name.endsWith('.swift') : name.endsWith('.kt'))),
619
+ )
553
620
  .map((name) => join(sub, name)),
554
621
  ),
555
622
  ]),
@@ -571,11 +638,14 @@ export function scanLocalNative(
571
638
  return result
572
639
  }
573
640
  const found = new Map<string, LocalNative>()
641
+ // a name an installed dependency asks for belongs to that dependency, whoever
642
+ // in the app spells it out
574
643
  const add = (kind: LocalNative['kind'], name: string, file: string, line: number) => {
575
- const packageName = owner(dirname(file))
576
- if (knownPackages.has(packageName)) return
577
644
  const key = `${kind}:${name}`
578
645
  if (found.has(key)) return
646
+ providers ??= dependencyNativeOwners(appDir, pkg)
647
+ const packageName = providers.get(key) ?? owner(dirname(file))
648
+ if (knownPackages.has(packageName)) return
579
649
  const moduleDir = expoModuleDirs.find((dir) => file.startsWith(`${dir}/`)) ?? null
580
650
  found.set(key, {
581
651
  kind,
@@ -584,8 +654,12 @@ export function scanLocalNative(
584
654
  path: `${relative(appDir, file)}:${line}`,
585
655
  expoModule: moduleDir ? relative(appDir, moduleDir) || '.' : null,
586
656
  swift: moduleDir
587
- ? (nativeFilesByModule.get(moduleDir) ?? []).some((path) =>
588
- path.endsWith('.swift'),
657
+ ? (nativeFilesByModule.get(moduleDir) ?? []).some(
658
+ (path) =>
659
+ path.endsWith('.swift') &&
660
+ [...(readSmallFile(path) ?? '').matchAll(/\bName\(\s*"(\w+)"\s*\)/g)].some(
661
+ (match) => match[1] === name,
662
+ ),
589
663
  )
590
664
  : false,
591
665
  answered: answered[kind].has(name),
@@ -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
@@ -60,6 +60,7 @@ export interface CloudBoxSession {
60
60
  platform?: RemotePlatform
61
61
  device?: string
62
62
  artifact?: CloudSessionArtifactIdentity
63
+ appFontsWireSha256?: string
63
64
  }
64
65
 
65
66
  export function normalizeCloudOrigin(source: string): string {
@@ -128,7 +129,13 @@ export function createCloudBoxSession(input: {
128
129
  platform?: RemotePlatform
129
130
  device?: string
130
131
  artifact?: CloudSessionArtifactIdentity
132
+ appFontsWireSha256?: string
131
133
  }): CloudBoxSession {
134
+ if (
135
+ input.appFontsWireSha256 !== undefined &&
136
+ !/^[a-f0-9]{64}$/.test(input.appFontsWireSha256)
137
+ )
138
+ throw new Error('cloud box session has an invalid app-font wire digest')
132
139
  const session: CloudBoxSession = {
133
140
  version: CLOUD_SESSION_VERSION,
134
141
  service: 'box',
@@ -138,6 +145,9 @@ export function createCloudBoxSession(input: {
138
145
  ...(input.platform ? { platform: input.platform } : {}),
139
146
  ...(input.device?.trim() ? { device: input.device.trim() } : {}),
140
147
  ...(input.artifact ? { artifact: input.artifact } : {}),
148
+ ...(input.appFontsWireSha256 !== undefined
149
+ ? { appFontsWireSha256: input.appFontsWireSha256 }
150
+ : {}),
141
151
  }
142
152
  if (!session.boxId || !session.boxToken) {
143
153
  throw new Error('cloud box session requires a box, an origin, and a box token')
@@ -198,6 +208,11 @@ export function parseCloudSession(encoded: string): CloudSession {
198
208
  try {
199
209
  if (parsed.service === 'box') {
200
210
  if (!boxId || !boxToken) throw new Error('unreadable cloud box session')
211
+ if (
212
+ parsed.appFontsWireSha256 !== undefined &&
213
+ typeof parsed.appFontsWireSha256 !== 'string'
214
+ )
215
+ throw new Error('unreadable app-font wire digest')
201
216
  return createCloudBoxSession({
202
217
  boxId,
203
218
  boxToken,
@@ -205,6 +220,7 @@ export function parseCloudSession(encoded: string): CloudSession {
205
220
  platform,
206
221
  device,
207
222
  artifact,
223
+ appFontsWireSha256: parsed.appFontsWireSha256,
208
224
  })
209
225
  }
210
226
  const simId = parsed.simId
@@ -162,11 +162,14 @@ interface VerbResult {
162
162
  reason?: string
163
163
  }
164
164
 
165
- async function runVerbOnce(verbArgs: string[]): Promise<VerbResult> {
165
+ async function runVerbOnce(
166
+ verbArgs: string[],
167
+ globalArgs: string[],
168
+ ): Promise<VerbResult> {
166
169
  // re-invoke self through the real CLI entry, so every code path the user's
167
170
  // verb would take, assert takes too.
168
171
  const { executable, prefixArgs } = rnxSelfInvocation()
169
- const args = [...prefixArgs, ...verbArgs, '--json']
172
+ const args = [...prefixArgs, ...globalArgs, ...verbArgs, '--json']
170
173
 
171
174
  return new Promise<VerbResult>((resolve) => {
172
175
  const proc = spawn(executable, args, {
@@ -402,7 +405,10 @@ function runJq(expr: string, payload: unknown): Promise<string> {
402
405
  })
403
406
  }
404
407
 
405
- export async function runAssert(rawArgs: string[]): Promise<void> {
408
+ export async function runAssert(
409
+ rawArgs: string[],
410
+ target: { port?: number; sim?: string } = {},
411
+ ): Promise<void> {
406
412
  if (rawArgs[0] === '--help' || rawArgs[0] === '-h' || rawArgs.length === 0) {
407
413
  printHelp()
408
414
  peachExit(rawArgs.length === 0 ? 2 : 0)
@@ -430,6 +436,13 @@ export async function runAssert(rawArgs: string[]): Promise<void> {
430
436
  const effectivePredicates =
431
437
  predicates.length > 0 ? predicates : [{ kind: '--exists', args: [] }]
432
438
 
439
+ // the child is a fresh CLI process: without the caller's explicit port and
440
+ // sim it falls back to the saved target.
441
+ const globalArgs = [
442
+ ...(target.port != null ? ['--port', String(target.port)] : []),
443
+ ...(target.sim ? ['--sim', target.sim] : []),
444
+ ]
445
+
433
446
  const deadline = withinMs > 0 ? Date.now() + withinMs : 0
434
447
  let attempts = 0
435
448
  let lastFailReason = ''
@@ -437,7 +450,7 @@ export async function runAssert(rawArgs: string[]): Promise<void> {
437
450
 
438
451
  while (true) {
439
452
  attempts++
440
- const verbResult = await runVerbOnce(verbArgs)
453
+ const verbResult = await runVerbOnce(verbArgs, globalArgs)
441
454
  if (!verbResult.ok) {
442
455
  lastVerbSkipReason = verbResult.reason || 'verb failed'
443
456
  if (Date.now() < deadline) {
@@ -10,6 +10,7 @@
10
10
  // name on the command line, else the one `peach box use` remembered. see
11
11
  // boxTarget below.
12
12
 
13
+ import { createHash } from 'node:crypto'
13
14
  import fs from 'node:fs'
14
15
  import path from 'node:path'
15
16
  import { createInterface } from 'node:readline'
@@ -536,7 +537,13 @@ export async function createPrebuiltBundleBox(options: {
536
537
  const nativeRenderer = options.nativeProjectDir
537
538
  ? readNativeProjectRenderer(options.nativeProjectDir, platform)
538
539
  : undefined
539
- return cloudBoxClient(options).create({
540
+ const appFonts = options.nativeProjectDir
541
+ ? readNativeLinkedAppFontsWire({
542
+ projectDir: options.nativeProjectDir,
543
+ platform,
544
+ })
545
+ : ''
546
+ const box = await cloudBoxClient(options).create({
540
547
  name: options.name,
541
548
  stack: prebuiltBundleBoxTemplate.stack,
542
549
  template: BOX_TEMPLATES.resolve({ name: prebuiltBundleBoxTemplate.name }).provenance,
@@ -544,16 +551,17 @@ export async function createPrebuiltBundleBox(options: {
544
551
  options.bundle,
545
552
  options.device,
546
553
  options.initialUrl ?? null,
547
- options.nativeProjectDir
548
- ? readNativeLinkedAppFontsWire({
549
- projectDir: options.nativeProjectDir,
550
- platform,
551
- })
552
- : '',
554
+ appFonts,
553
555
  nativeRenderer,
554
556
  ),
555
557
  ...(options.secrets ? { secrets: options.secrets } : {}),
556
558
  })
559
+ return {
560
+ box,
561
+ appFontsWireSha256: box.existing
562
+ ? undefined
563
+ : createHash('sha256').update(appFonts).digest('hex'),
564
+ }
557
565
  }
558
566
 
559
567
  // a plain .env file: `NAME=value` lines, an optional `export `, optional
@@ -698,7 +706,7 @@ async function runCloudBoxCreate(
698
706
  }
699
707
  let session: BoxShellSession | null = null
700
708
  try {
701
- const box =
709
+ const created =
702
710
  'bundle' in source
703
711
  ? await createPrebuiltBundleBox({
704
712
  ...connection,
@@ -708,21 +716,27 @@ async function runCloudBoxCreate(
708
716
  name,
709
717
  ...(secrets ? { secrets } : {}),
710
718
  })
711
- : await cloudBoxClient(connection).create({
712
- name,
713
- stack: template.stack,
714
- template: template.provenance,
715
- source: source.checkout,
716
- ...(secrets ? { secrets } : {}),
717
- onProgress:
718
- opts.verbose && !json
719
- ? (seeded, total) => console.log(` ...seeded ${seeded}/${total} files`)
720
- : undefined,
721
- })
719
+ : {
720
+ box: await cloudBoxClient(connection).create({
721
+ name,
722
+ stack: template.stack,
723
+ template: template.provenance,
724
+ source: source.checkout,
725
+ ...(secrets ? { secrets } : {}),
726
+ onProgress:
727
+ opts.verbose && !json
728
+ ? (seeded, total) => console.log(` ...seeded ${seeded}/${total} files`)
729
+ : undefined,
730
+ }),
731
+ }
732
+ const { box } = created
722
733
  if (json) {
723
734
  console.log(
724
735
  JSON.stringify({
725
736
  ...box.description,
737
+ ...('appFontsWireSha256' in created
738
+ ? { appFontsWireSha256: created.appFontsWireSha256 }
739
+ : {}),
726
740
  ...(box.accessToken ? { accessToken: box.accessToken } : {}),
727
741
  }),
728
742
  )