peachsim 0.1.1620 → 0.1.1635

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +45 -6
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +39 -18
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +248 -156
  7. package/cli/cloud-client.ts +68 -57
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/box/search-command.ts +3 -2
  10. package/cli/commands/box.ts +10 -29
  11. package/cli/commands/control.ts +391 -92
  12. package/cli/commands/doctor.ts +17 -184
  13. package/cli/commands/flow.ts +79 -11
  14. package/cli/commands/inspect/core.ts +10 -5
  15. package/cli/commands/inspect/settling.ts +71 -22
  16. package/cli/commands/inspect/wait-ready.ts +12 -0
  17. package/cli/commands/inspect.ts +26 -3
  18. package/cli/commands/install-desktop.ts +1 -0
  19. package/cli/commands/maestro-compare.ts +105 -67
  20. package/cli/commands/maestro.ts +9 -1
  21. package/cli/commands/platform.ts +71 -36
  22. package/cli/commands/record.ts +21 -1
  23. package/cli/commands/screenshot.ts +23 -1
  24. package/cli/commands/serve.ts +7 -2
  25. package/cli/commands/setup.ts +19 -42
  26. package/cli/commands/swift.ts +5 -0
  27. package/cli/commands/upload.ts +51 -8
  28. package/cli/desktop-companion.ts +102 -17
  29. package/cli/dev-build-freshness.ts +37 -2
  30. package/cli/drivers/desktop.ts +14 -1
  31. package/cli/drivers/playwright-sim-host.ts +44 -8
  32. package/cli/drivers/playwright.ts +9 -8
  33. package/cli/drivers/types.ts +5 -0
  34. package/cli/maestro-accessibility.ts +53 -0
  35. package/cli/maestro-compare.ts +108 -12
  36. package/cli/main.ts +2 -1
  37. package/cli/outbound-endpoints.ts +35 -6
  38. package/cli/parent-pid.ts +0 -9
  39. package/cli/pause-aware-timeout.ts +102 -0
  40. package/cli/remote-simulators.ts +112 -0
  41. package/cli/remoteSimulatorContract.ts +21 -0
  42. package/cli/scene-video.ts +89 -0
  43. package/cli/ws-bridge.ts +23 -14
  44. package/detox/attempt-events.cjs +4 -0
  45. package/detox/expectations.ts +5 -1
  46. package/detox/index.ts +241 -181
  47. package/detox/motion-change.cjs +73 -0
  48. package/detox/motion-change.d.cts +21 -0
  49. package/detox/navigation-lifecycle.ts +22 -4
  50. package/dist-lib/agent-daemon-client.cjs +3 -2
  51. package/dist-lib/agent-events.cjs +1 -1
  52. package/dist-lib/attached-projects.cjs +1 -1
  53. package/dist-lib/auth/shared-session.cjs +3 -2
  54. package/dist-lib/backend-origin.cjs +1 -1
  55. package/dist-lib/beta.cjs +1 -1
  56. package/dist-lib/beta.mjs +1 -1
  57. package/dist-lib/bridge-constants.cjs +13 -1
  58. package/dist-lib/bridge-contract-input.cjs +1 -1
  59. package/dist-lib/bridge-contract-input.mjs +1 -1
  60. package/dist-lib/bridge-contract.cjs +14 -8
  61. package/dist-lib/bridge-contract.mjs +13 -7
  62. package/dist-lib/bundle-channel.cjs +10480 -7962
  63. package/dist-lib/capture-contract.cjs +1 -1
  64. package/dist-lib/capture-contract.mjs +1 -1
  65. package/dist-lib/cloud-contract.cjs +1 -1
  66. package/dist-lib/cloud-contract.mjs +1 -1
  67. package/dist-lib/cloud-test-args.cjs +1 -1
  68. package/dist-lib/cloud-test-args.mjs +1 -1
  69. package/dist-lib/cloud.cjs +3 -2
  70. package/dist-lib/cloud.mjs +3 -2
  71. package/dist-lib/config.cjs +10 -6
  72. package/dist-lib/config.mjs +10 -6
  73. package/dist-lib/detox/index.cjs +1854 -966
  74. package/dist-lib/dev-bundle-resolution.cjs +64 -24
  75. package/dist-lib/expo-swift-bundle.cjs +8715 -6271
  76. package/dist-lib/expo-swift-metadata.cjs +7 -4
  77. package/dist-lib/expo-swift-metadata.mjs +7 -4
  78. package/dist-lib/home-paths.cjs +32 -3
  79. package/dist-lib/host/bridge-host.cjs +10135 -7060
  80. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  81. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  82. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  83. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  84. package/dist-lib/host/websocket-proxy.cjs +1 -1
  85. package/dist-lib/index.cjs +1843 -408
  86. package/dist-lib/jump-to-source-babel.cjs +1 -1
  87. package/dist-lib/jump-to-source-native.cjs +1 -1
  88. package/dist-lib/kotlin-host.cjs +1 -1
  89. package/dist-lib/menu.cjs +3 -2
  90. package/dist-lib/menu.mjs +3 -2
  91. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  92. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  93. package/dist-lib/metro-production-bundle.cjs +5 -4
  94. package/dist-lib/metro-production-bundle.mjs +5 -4
  95. package/dist-lib/metro.cjs +1182 -418
  96. package/dist-lib/native-app-identity-project.cjs +782 -23
  97. package/dist-lib/native-app-identity-project.mjs +781 -23
  98. package/dist-lib/native-app-identity.cjs +276 -9
  99. package/dist-lib/native-app-identity.mjs +273 -8
  100. package/dist-lib/native-firebase-metadata.cjs +5 -4
  101. package/dist-lib/native-firebase-metadata.mjs +5 -4
  102. package/dist-lib/plist.cjs +1 -1
  103. package/dist-lib/plist.mjs +1 -1
  104. package/dist-lib/profiles.cjs +1 -1
  105. package/dist-lib/public-brand.cjs +3 -2
  106. package/dist-lib/react-native-host-modules.cjs +8 -1
  107. package/dist-lib/react-native-host-modules.mjs +8 -1
  108. package/dist-lib/scripts/dev-server-scanner.cjs +106 -28
  109. package/dist-lib/sdk.cjs +115 -34
  110. package/dist-lib/sdk.mjs +115 -33
  111. package/dist-lib/skills.cjs +15274 -9904
  112. package/dist-lib/swift-compile.cjs +1165 -173
  113. package/dist-lib/swift-compile.mjs +1212 -203
  114. package/dist-lib/swift-package.cjs +12 -3
  115. package/dist-lib/swift-package.mjs +12 -3
  116. package/dist-lib/swift-submit.cjs +7 -1
  117. package/dist-lib/swift-submit.mjs +5 -1
  118. package/dist-lib/swift-toolchain.cjs +133 -18
  119. package/dist-lib/swift.cjs +1 -1
  120. package/dist-lib/vite-swift.cjs +2648 -862
  121. package/dist-lib/vite.cjs +70 -14
  122. package/dist-lib/worklet-executable.cjs +25 -2
  123. package/dist-lib/worklet-executable.mjs +25 -2
  124. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  125. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  126. package/package.json +2 -2
  127. package/scripts/dev-server-scanner.ts +35 -14
  128. package/skills/peach-perf/SKILL.md +11 -5
  129. package/skills/peach-setup/SKILL.md +16 -19
  130. package/src/admitted-mac-build.ts +34 -8
  131. package/src/android-app-identity.ts +146 -0
  132. package/src/android-autolinking.ts +75 -0
  133. package/src/android-string-value.ts +36 -0
  134. package/src/android-strings.ts +5 -36
  135. package/src/androidAutolinkingContract.ts +40 -0
  136. package/src/bridge-constants.ts +11 -0
  137. package/src/bridge-contract.ts +22 -34
  138. package/src/bundleGlobalFooter.ts +26 -7
  139. package/src/codex-client.ts +3 -2
  140. package/src/config.ts +15 -0
  141. package/src/desktop-host-delivery.ts +5 -1
  142. package/src/dev-bundle-resolution.ts +60 -26
  143. package/src/expo-kotlin-host.ts +6 -36
  144. package/src/expo-manifest-body.ts +13 -0
  145. package/src/expo-swift-bundle.ts +17 -12
  146. package/src/expo-swift-host.ts +640 -192
  147. package/src/expo-swift-ready.ts +78 -0
  148. package/src/expoSwiftContract.ts +19 -0
  149. package/src/home-paths.ts +48 -2
  150. package/src/host/bridge-host.ts +12 -57
  151. package/src/host/staged-host-files.ts +29 -2
  152. package/src/installed-package-versions.ts +54 -14
  153. package/src/launchMetadataContract.ts +24 -0
  154. package/src/metro-plugin.ts +37 -42
  155. package/src/native-app-identity-project.ts +142 -5
  156. package/src/native-app-identity.ts +176 -11
  157. package/src/native-firebase-config.ts +209 -13
  158. package/src/nitro-compile.ts +2 -5
  159. package/src/nitro-source.ts +147 -63
  160. package/src/process-group.ts +114 -61
  161. package/src/process-output.ts +0 -9
  162. package/src/public-brand.ts +1 -0
  163. package/src/react-native-bridge-modules.ts +416 -0
  164. package/src/react-native-host-modules.ts +7 -0
  165. package/src/sim-client.ts +6 -4
  166. package/src/swift-binary-modules.ts +2 -2
  167. package/src/swift-compile.ts +1007 -108
  168. package/src/swift-compiler-digest.ts +14 -0
  169. package/src/swift-distribution.lock.json +40 -12
  170. package/src/swift-distribution.ts +61 -9
  171. package/src/swift-host.ts +149 -56
  172. package/src/swift-icu.ts +14 -6
  173. package/src/swift-objc-sdk.ts +133 -10
  174. package/src/swift-objc-standin.ts +38 -3
  175. package/src/swift-package-manifest.ts +21 -6
  176. package/src/swift-package.ts +15 -1
  177. package/src/swift-packages.ts +119 -60
  178. package/src/swift-standin-emit.ts +57 -9
  179. package/src/swift-standins.ts +185 -25
  180. package/src/swift-submit.ts +10 -0
  181. package/src/swift-toolchain.ts +149 -23
  182. package/src/vite-plugin.ts +20 -20
  183. package/src/worklet-executable.ts +30 -3
  184. package/src/worklets-babel.ts +35 -14
  185. package/src/xcode-project.ts +10 -0
@@ -4,8 +4,7 @@
4
4
  // native code Peach does not know with how to stub it, and a no-login report.
5
5
 
6
6
  import { createHash } from 'node:crypto'
7
- import { existsSync, lstatSync, realpathSync } from 'node:fs'
8
- import { dirname, join, relative, resolve } from 'node:path'
7
+ import { relative, resolve } from 'node:path'
9
8
  import { PURE_JS_PACKAGES } from '@peach/compat/pure-js-whitelist'
10
9
  import { resolveCompatVersionRange } from '@peach/compat/version-range'
11
10
  import {
@@ -20,11 +19,11 @@ import { rnxPublicBrand } from '../../src/public-brand'
20
19
  import { resolveRNXAppConfig } from '../app-config'
21
20
  import {
22
21
  installedPackageDir,
23
- listDir,
22
+ hasNativeCode,
23
+ scanLocalNative,
24
+ type LocalNative,
25
+ type AnsweredNames,
24
26
  packageNativeRequests,
25
- sourceNativeRequests,
26
- readSmallFile,
27
- SOURCE_FILE,
28
27
  type AppStubs,
29
28
  type AppliedStubs,
30
29
  type NativeRequest as PackageNativeRequest,
@@ -33,11 +32,8 @@ import { readRuntimeCompatibility } from '../runtime-compat'
33
32
  import { accent, bold, failure, muted, success, warning } from '../ui'
34
33
  import {
35
34
  findProjectRoot,
36
- scanRepository,
37
35
  scanWorkspace,
38
36
  installedManifest,
39
- metadataValue,
40
- readProjectJson,
41
37
  readProjectManifest,
42
38
  } from './detect'
43
39
  import { sanitizeProjectReportName, sanitizeProjectReportVersion } from './project-report'
@@ -129,7 +125,18 @@ export async function runDoctor(args: string[]): Promise<number> {
129
125
  versions: checkVersions(target.installed, runtime.appSupport),
130
126
  scan: target.scan,
131
127
  unknownNative: findUnknownNative(target.dir, target.scan, answered),
132
- local: scanLocalNative(target.dir, target.manifest, answered, target.scan),
128
+ local: scanLocalNative(
129
+ target.dir,
130
+ target.manifest,
131
+ answered,
132
+ target.scan.scoreSummary.platform,
133
+ new Set([
134
+ ...PURE_JS_PACKAGES,
135
+ ...target.scan.packages
136
+ .filter((item) => item.rnx.source !== 'untracked')
137
+ .map((item) => item.name),
138
+ ]),
139
+ ),
133
140
  }
134
141
 
135
142
  if (json) printJson(findings)
@@ -817,23 +824,6 @@ function findUnknownNative(
817
824
  })
818
825
  }
819
826
 
820
- function hasNativeCode(
821
- dir: string,
822
- platform: ScanResult['scoreSummary']['platform'],
823
- ): boolean {
824
- const expoConfig = join(dir, 'expo-module.config.json')
825
- const platforms = metadataValue(readProjectJson(expoConfig), 'platforms')
826
- if (Array.isArray(platforms))
827
- return (
828
- platforms.includes(platform) || (platform === 'ios' && platforms.includes('apple'))
829
- )
830
- if ([platform, 'expo-module.config.json'].some((entry) => existsSync(join(dir, entry))))
831
- return true
832
- if (listDir(dir).some((name) => name.endsWith('.podspec'))) return true
833
- const manifest = readSmallFile(join(dir, 'package.json'))
834
- return manifest !== null && manifest.includes('"codegenConfig"')
835
- }
836
-
837
827
  function reportLocalNative(local: readonly LocalNative[]): void {
838
828
  for (const item of local) {
839
829
  const where = item.expoModule
@@ -845,26 +835,6 @@ function reportLocalNative(local: readonly LocalNative[]): void {
845
835
  }
846
836
  }
847
837
 
848
- // an app's own native code, found from the names its JavaScript asks the native
849
- // side for and from local Expo modules (a directory with expo-module.config.json).
850
- // each name is a key under its package in peach.config.ts `packages`.
851
- export interface LocalNative {
852
- kind: 'module' | 'view'
853
- name: string
854
- // the package the name belongs to: the nearest package.json with a name
855
- package: string
856
- // first place the name appears, relative to the app, as file:line
857
- path: string
858
- // the local Expo module directory the name belongs to, relative to the app
859
- expoModule: string | null
860
- swift: boolean
861
- // a stub already answers it
862
- answered: boolean
863
- }
864
-
865
- const NATIVE_SOURCE_FILE = /\.(?:swift|kt)$/
866
- type AnsweredNames = Record<LocalNative['kind'], Set<string>>
867
-
868
838
  function answeredNames(
869
839
  config: Awaited<ReturnType<typeof resolveRNXAppConfig>>['config'],
870
840
  ): AnsweredNames {
@@ -878,143 +848,6 @@ function answeredNames(
878
848
  }
879
849
  }
880
850
 
881
- function scanLocalNative(
882
- appDir: string,
883
- pkg: ProjectManifest,
884
- answered: AnsweredNames,
885
- scan: ScanResult,
886
- ): LocalNative[] {
887
- // linking a known dependency into a workspace does not make it app-owned native code.
888
- const knownPackages = new Set([
889
- ...PURE_JS_PACKAGES,
890
- ...scan.packages
891
- .filter((item) => item.rnx.source !== 'untracked')
892
- .map((item) => item.name),
893
- ])
894
- const expoModuleDirs: string[] = []
895
- const excludedModuleDirs: string[] = []
896
- const sourceFiles: string[] = []
897
- for (const root of [appDir, ...workspacePackageDirs(appDir, pkg)]) {
898
- for (const dir of scanRepository(root).directories) {
899
- if (
900
- excludedModuleDirs.some(
901
- (excluded) => dir === excluded || dir.startsWith(`${excluded}/`),
902
- )
903
- )
904
- continue
905
- if (existsSync(join(dir, 'expo-module.config.json'))) {
906
- if (!hasNativeCode(dir, scan.scoreSummary.platform)) {
907
- excludedModuleDirs.push(dir)
908
- continue
909
- }
910
- expoModuleDirs.push(dir)
911
- }
912
- for (const name of listDir(dir)) {
913
- if (SOURCE_FILE.test(name) && !name.endsWith('.d.ts'))
914
- sourceFiles.push(join(dir, name))
915
- }
916
- }
917
- }
918
- const nativeFilesByModule = new Map(
919
- expoModuleDirs.map((dir) => [
920
- dir,
921
- scanRepository(dir).directories.flatMap((sub) =>
922
- listDir(sub)
923
- .filter((name) => NATIVE_SOURCE_FILE.test(name))
924
- .map((name) => join(sub, name)),
925
- ),
926
- ]),
927
- )
928
-
929
- const owners = new Map<string, string>()
930
- const owner = (dir: string): string => {
931
- const known = owners.get(dir)
932
- if (known) return known
933
- const name = readSmallFile(join(dir, 'package.json'))?.match(
934
- /"name"\s*:\s*"([^"]+)"/,
935
- )?.[1]
936
- const parent = dirname(dir)
937
- const result = name ?? (parent === dir ? (pkg.name ?? 'app') : owner(parent))
938
- owners.set(dir, result)
939
- return result
940
- }
941
- const found = new Map<string, LocalNative>()
942
- const add = (kind: LocalNative['kind'], name: string, file: string, line: number) => {
943
- const packageName = owner(dirname(file))
944
- if (knownPackages.has(packageName)) return
945
- const key = `${kind}:${name}`
946
- if (found.has(key)) return
947
- const moduleDir = expoModuleDirs.find((dir) => file.startsWith(`${dir}/`)) ?? null
948
- found.set(key, {
949
- kind,
950
- name,
951
- package: packageName,
952
- path: `${relative(appDir, file)}:${line}`,
953
- expoModule: moduleDir ? relative(appDir, moduleDir) || '.' : null,
954
- swift: moduleDir
955
- ? (nativeFilesByModule.get(moduleDir) ?? []).some((path) =>
956
- path.endsWith('.swift'),
957
- )
958
- : false,
959
- answered: answered[kind].has(name),
960
- })
961
- }
962
- for (const file of sourceFiles) {
963
- const text = readSmallFile(file)
964
- if (!text) continue
965
- for (const { kind, name, line } of sourceNativeRequests(file, text))
966
- add(kind, name, file, line)
967
- }
968
- // a local expo module declares its name in its native definition, Name("X"),
969
- // whether or not the app's javascript spells it out. swift first, so a
970
- // module the report marks Swift points at its swift definition.
971
- for (const [, files] of nativeFilesByModule) {
972
- const swiftFirst = [...files].sort(
973
- (a, b) => Number(b.endsWith('.swift')) - Number(a.endsWith('.swift')),
974
- )
975
- for (const file of swiftFirst) {
976
- const text = readSmallFile(file)
977
- if (!text) continue
978
- for (const match of text.matchAll(/\bName\(\s*"(\w+)"\s*\)/g)) {
979
- if (match[1])
980
- add('module', match[1], file, text.slice(0, match.index).split('\n').length)
981
- }
982
- }
983
- }
984
- return [...found.values()].sort(
985
- (a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name),
986
- )
987
- }
988
-
989
- // dependencies installed as links to source outside any node_modules are the
990
- // app's own workspace packages, which hold its native code as often as the app
991
- // directory does.
992
- function workspacePackageDirs(appDir: string, pkg: ProjectManifest): string[] {
993
- const dirs = new Set<string>()
994
- for (const name of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })) {
995
- let current = appDir
996
- while (true) {
997
- const link = join(current, 'node_modules', name)
998
- if (existsSync(link)) {
999
- if (lstatSync(link).isSymbolicLink()) {
1000
- const target = realpathSync(link)
1001
- if (
1002
- !target.split('/').includes('node_modules') &&
1003
- !target.startsWith(`${appDir}/`)
1004
- ) {
1005
- dirs.add(target)
1006
- }
1007
- }
1008
- break
1009
- }
1010
- const parent = dirname(current)
1011
- if (parent === current) break
1012
- current = parent
1013
- }
1014
- }
1015
- return [...dirs]
1016
- }
1017
-
1018
851
  function scanOnePackage(
1019
852
  appDir: string,
1020
853
  pkg: ProjectManifest,
@@ -434,20 +434,20 @@ flow extension:
434
434
  const remapArgs = args.flatMap((arg, index) =>
435
435
  args[index - 1] === '--remap' ? ['--remap', arg] : [],
436
436
  )
437
- // --preview implies --record: the preview page layers the mp4 over
438
- // the canvas during scrub so the viewer sees smooth motion while the
439
- // real app is catching up. upload chains the video in automatically.
440
- const record = args.includes('--record') || preview
437
+ // CI previews export video from the saved scene after the take. desktop
438
+ // previews can also record live video; explicit --record always requests it.
439
+ const record =
440
+ args.includes('--record') || (preview && process.env.GITHUB_ACTIONS !== 'true')
441
441
  // hold the final UI state in the recording so viewers see the result
442
442
  // of the flow, not just a frame mid-transition. short flows otherwise
443
443
  // cut off ~600ms in. caller can override with --tail-wait <ms>.
444
444
  const tailWaitMs = args.includes('--tail-wait')
445
445
  ? Math.max(0, +(getFlag('--tail-wait') || '2000'))
446
- : record
447
- ? preview
448
- ? 6000
449
- : 2000
450
- : 0
446
+ : preview
447
+ ? 6000
448
+ : record
449
+ ? 2000
450
+ : 0
451
451
 
452
452
  // parse flow file (frontmatter + steps). `${...}` templates stay verbatim
453
453
  // here — the runner interpolates per step at execution time.
@@ -730,6 +730,9 @@ flow extension:
730
730
  let videoPath: string | null = null
731
731
  let videoDurationMs: number | null = null
732
732
  let capturedEvents: Array<Record<string, any>> | null = null
733
+ let sceneRecording = false
734
+ let scenePath: string | null = null
735
+ let previewRecordingStartedAtMs: number | null = null
733
736
  let previewScreenshotPath: string | null = null
734
737
  let failureBundleDir: string | null = null
735
738
  let exitCode = 0
@@ -799,14 +802,17 @@ flow extension:
799
802
  simId: driver.simId,
800
803
  code: `(() => {
801
804
  const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
802
- return r?.start?.() ?? false
805
+ const startedAtMs = Date.now()
806
+ return r?.start?.(startedAtMs) ? startedAtMs : null
803
807
  })()`,
804
808
  })
809
+ if (typeof started === 'number') previewRecordingStartedAtMs = started
805
810
  if (!started) {
806
811
  console.warn(
807
812
  ' warn: eventRecorder.start() returned false — preview will upload bundle without replay events',
808
813
  )
809
814
  }
815
+ sceneRecording = await startPreviewSceneRecording(bridge, driver.simId)
810
816
  }
811
817
  await hooks.beforeSteps?.()
812
818
  if (hooks.runSteps) await hooks.runSteps(driver, steps)
@@ -830,6 +836,10 @@ flow extension:
830
836
  })()`,
831
837
  })) as Array<Record<string, any>>
832
838
  console.log(` captured ${capturedEvents.length} replay events`)
839
+ if (sceneRecording) {
840
+ sceneRecording = false
841
+ scenePath = await stopPreviewSceneRecording(bridge, driver.simId)
842
+ }
833
843
  previewScreenshotPath = path.join(screenshotDir, 'preview-final.png')
834
844
  await driver.captureScreenshot(previewScreenshotPath)
835
845
  console.log(` final screenshot: ${previewScreenshotPath}`)
@@ -887,6 +897,10 @@ flow extension:
887
897
  )
888
898
  }
889
899
  }
900
+ if (sceneRecording) {
901
+ sceneRecording = false
902
+ scenePath = await stopPreviewSceneRecording(bridge, driver.simId)
903
+ }
890
904
  lastFlowError = err.message
891
905
  console.error(`\n x failed: ${err.message}\n`)
892
906
  if (profileResult) {
@@ -959,6 +973,7 @@ flow extension:
959
973
  // canvas during scrub for smooth motion while the real app
960
974
  // catches up.
961
975
  if (videoPath) uploadArgs.push('--video', videoPath)
976
+ if (scenePath) uploadArgs.push('--scene', scenePath)
962
977
  if (previewScreenshotPath) {
963
978
  uploadArgs.push('--screenshot', previewScreenshotPath)
964
979
  }
@@ -966,7 +981,8 @@ flow extension:
966
981
  uploadArgs.push('--video-duration-ms', String(Math.round(videoDurationMs)))
967
982
  uploadArgs.push('--recorded-duration-ms', String(Math.round(videoDurationMs)))
968
983
  }
969
- const recordingStartedAtMs = driver.getLastRecordingStartedAtMs()
984
+ const recordingStartedAtMs =
985
+ driver.getLastRecordingStartedAtMs() ?? previewRecordingStartedAtMs
970
986
  if (recordingStartedAtMs && Number.isFinite(recordingStartedAtMs)) {
971
987
  uploadArgs.push(
972
988
  '--recording-started-at-ms',
@@ -1151,6 +1167,58 @@ async function prepareEventsFile(events: Array<Record<string, any>>): Promise<st
1151
1167
  return outPath
1152
1168
  }
1153
1169
 
1170
+ // the tab's scene stream rides the preview share. a tab with no app on screen
1171
+ // has none to record, and the share keeps its events and video.
1172
+ async function startPreviewSceneRecording(
1173
+ bridge: ReturnType<typeof createBridge>,
1174
+ simId: string | undefined,
1175
+ ): Promise<boolean> {
1176
+ try {
1177
+ await bridge.send({
1178
+ type: 'evaluate',
1179
+ simId,
1180
+ code: `window.SootSim.bridges.sceneStream.run({ action: 'recordStart' })`,
1181
+ })
1182
+ return true
1183
+ } catch (err) {
1184
+ console.warn(
1185
+ ` warn: scene stream not recorded: ${err instanceof Error ? err.message : String(err)}`,
1186
+ )
1187
+ return false
1188
+ }
1189
+ }
1190
+
1191
+ // write the recorded scene stream to a temp file for the upload command.
1192
+ async function stopPreviewSceneRecording(
1193
+ bridge: ReturnType<typeof createBridge>,
1194
+ simId: string | undefined,
1195
+ ): Promise<string | null> {
1196
+ try {
1197
+ const encoded = await bridge.send({
1198
+ type: 'evaluate',
1199
+ simId,
1200
+ code: `(async () => {
1201
+ const { bytes } = await window.SootSim.bridges.sceneStream.run({ action: 'recordStop' })
1202
+ let text = ''
1203
+ for (let i = 0; i < bytes.length; i += 0x8000)
1204
+ text += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
1205
+ return btoa(text)
1206
+ })()`,
1207
+ })
1208
+ if (typeof encoded !== 'string') throw new Error('the tab returned no scene stream')
1209
+ const bytes = Buffer.from(encoded, 'base64')
1210
+ const outPath = path.join(tmpdir(), `peach-scene-${Date.now()}.psr.gz`)
1211
+ fs.writeFileSync(outPath, bytes)
1212
+ console.log(` scene stream: ${outPath} (${bytes.length} bytes gz)`)
1213
+ return outPath
1214
+ } catch (err) {
1215
+ console.warn(
1216
+ ` warn: scene stream stop failed: ${err instanceof Error ? err.message : String(err)}`,
1217
+ )
1218
+ return null
1219
+ }
1220
+ }
1221
+
1154
1222
  async function prepareFlowStepTimelineFile(
1155
1223
  steps: SootSimFlowTraceStep[],
1156
1224
  ): Promise<string> {
@@ -13,17 +13,14 @@
13
13
 
14
14
  import {
15
15
  HEADLESS_TENANT_FETCH_HUNG_MARKER,
16
+ readyProbeContentBasis,
16
17
  readyProbeIsReady,
17
18
  type SemanticWaitCondition,
18
19
  type SemanticWaitResult,
19
20
  type WsBridgeCommand,
20
21
  } from '../../../src/bridge-contract'
21
22
  import { SEMANTIC_WAIT_MAX_TIMEOUT_MS } from '../../../src/bridge-contract-input'
22
- export {
23
- readyProbeHasContent,
24
- readyProbeHasTargetContent,
25
- readyProbeIsReady,
26
- } from '../../../src/bridge-contract'
23
+ export { readyProbeHasContent, readyProbeIsReady } from '../../../src/bridge-contract'
27
24
  import type {
28
25
  SimSemanticNode,
29
26
  SimSemanticQueryResult,
@@ -1175,6 +1172,11 @@ export interface WaitReadyStatus {
1175
1172
  externalError: string
1176
1173
  suppressedEntryError: string
1177
1174
  errors: number
1175
+ // the probe fields that decide readiness, so a ready result shows why it
1176
+ // returned: which content condition held and what painted.
1177
+ paintedAppContent: boolean
1178
+ treeQuietMs: number
1179
+ readyBasis: string
1178
1180
  // non-empty when the probe itself stopped reaching the sim (tab closed,
1179
1181
  // sim never connected) — the wait failed on the bridge, not on the app.
1180
1182
  bridgeError: string
@@ -1422,6 +1424,9 @@ function waitReadyStatus(result: SemanticWaitResult, ready: boolean): WaitReadyS
1422
1424
  suppressedEntryError: probe?.suppressedEntryError ?? '',
1423
1425
  errors: probe?.errors ?? 0,
1424
1426
  bridgeError: result.error ?? '',
1427
+ paintedAppContent: probe?.paintedAppContent ?? false,
1428
+ treeQuietMs: probe?.treeQuietMs ?? 0,
1429
+ readyBasis: probe ? readyProbeContentBasis(probe) : '',
1425
1430
  }
1426
1431
  }
1427
1432