peachsim 0.1.1625 → 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 (169) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -2
  3. package/cli/app-config.ts +18 -6
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +284 -20
  6. package/cli/bridge-flow-runner.ts +66 -45
  7. package/cli/cloud-client.ts +60 -40
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/control.ts +283 -49
  10. package/cli/commands/doctor.ts +30 -189
  11. package/cli/commands/flow.ts +79 -11
  12. package/cli/commands/inspect/core.ts +10 -5
  13. package/cli/commands/inspect/settling.ts +14 -2
  14. package/cli/commands/inspect/shared.ts +9 -4
  15. package/cli/commands/inspect/wait-ready.ts +12 -0
  16. package/cli/commands/inspect.ts +91 -50
  17. package/cli/commands/install-desktop.ts +1 -0
  18. package/cli/commands/maestro-compare.ts +97 -38
  19. package/cli/commands/maestro.ts +9 -1
  20. package/cli/commands/preview.ts +1 -1
  21. package/cli/commands/record.ts +118 -48
  22. package/cli/commands/screenshot.ts +23 -1
  23. package/cli/commands/serve.ts +7 -2
  24. package/cli/commands/setup.ts +19 -42
  25. package/cli/commands/swift.ts +14 -3
  26. package/cli/commands/upload.ts +93 -48
  27. package/cli/desktop-companion.ts +102 -17
  28. package/cli/drivers/desktop.ts +14 -1
  29. package/cli/drivers/playwright-sim-host.ts +103 -45
  30. package/cli/drivers/playwright.ts +9 -8
  31. package/cli/drivers/types.ts +5 -0
  32. package/cli/maestro-accessibility.ts +1 -1
  33. package/cli/maestro-compare.ts +84 -12
  34. package/cli/main.ts +3 -2
  35. package/cli/outbound-endpoints.ts +20 -6
  36. package/cli/pause-aware-timeout.ts +106 -0
  37. package/cli/scene-video.ts +89 -0
  38. package/cli/ws-bridge.ts +37 -19
  39. package/detox/attempt-events.cjs +66 -5
  40. package/detox/attempt-events.d.cts +7 -0
  41. package/detox/expectations.ts +7 -3
  42. package/detox/index.ts +269 -170
  43. package/detox/motion-change.cjs +206 -21
  44. package/detox/motion-change.d.cts +39 -2
  45. package/detox/navigation-lifecycle.ts +35 -3
  46. package/dist-lib/agent-daemon-client.cjs +1 -1
  47. package/dist-lib/agent-events.cjs +1 -1
  48. package/dist-lib/attached-projects.cjs +1 -1
  49. package/dist-lib/auth/shared-session.cjs +1 -1
  50. package/dist-lib/backend-origin.cjs +8 -7
  51. package/dist-lib/beta.cjs +1 -1
  52. package/dist-lib/beta.mjs +1 -1
  53. package/dist-lib/bridge-constants.cjs +13 -1
  54. package/dist-lib/bridge-contract-input.cjs +1 -1
  55. package/dist-lib/bridge-contract-input.mjs +1 -1
  56. package/dist-lib/bridge-contract.cjs +14 -8
  57. package/dist-lib/bridge-contract.mjs +13 -7
  58. package/dist-lib/bundle-channel.cjs +11887 -7900
  59. package/dist-lib/capture-contract.cjs +1 -1
  60. package/dist-lib/capture-contract.mjs +1 -1
  61. package/dist-lib/cloud-contract.cjs +1 -1
  62. package/dist-lib/cloud-contract.mjs +1 -1
  63. package/dist-lib/cloud-test-args.cjs +1 -1
  64. package/dist-lib/cloud-test-args.mjs +1 -1
  65. package/dist-lib/cloud.cjs +1 -1
  66. package/dist-lib/cloud.mjs +1 -1
  67. package/dist-lib/config.cjs +6 -3
  68. package/dist-lib/config.mjs +6 -3
  69. package/dist-lib/detox/index.cjs +1379 -725
  70. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  71. package/dist-lib/expo-swift-bundle.cjs +9341 -5462
  72. package/dist-lib/expo-swift-metadata.cjs +11 -7
  73. package/dist-lib/expo-swift-metadata.mjs +11 -7
  74. package/dist-lib/home-paths.cjs +32 -3
  75. package/dist-lib/host/bridge-host.cjs +50204 -7258
  76. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  77. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  78. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  79. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  80. package/dist-lib/host/websocket-proxy.cjs +1 -1
  81. package/dist-lib/index.cjs +3541 -448
  82. package/dist-lib/jump-to-source-babel.cjs +1 -1
  83. package/dist-lib/jump-to-source-native.cjs +1 -1
  84. package/dist-lib/kotlin-host.cjs +1 -1
  85. package/dist-lib/menu.cjs +1 -1
  86. package/dist-lib/menu.mjs +1 -1
  87. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  88. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  89. package/dist-lib/metro-production-bundle.cjs +1 -1
  90. package/dist-lib/metro-production-bundle.mjs +1 -1
  91. package/dist-lib/metro.cjs +2931 -104
  92. package/dist-lib/native-app-identity-project.cjs +289 -49
  93. package/dist-lib/native-app-identity-project.mjs +289 -49
  94. package/dist-lib/native-app-identity.cjs +77 -26
  95. package/dist-lib/native-app-identity.mjs +76 -26
  96. package/dist-lib/native-firebase-metadata.cjs +1 -1
  97. package/dist-lib/native-firebase-metadata.mjs +1 -1
  98. package/dist-lib/plist.cjs +1 -1
  99. package/dist-lib/plist.mjs +1 -1
  100. package/dist-lib/profiles.cjs +1 -1
  101. package/dist-lib/public-brand.cjs +1 -1
  102. package/dist-lib/react-native-host-modules.cjs +6 -2
  103. package/dist-lib/react-native-host-modules.mjs +6 -2
  104. package/dist-lib/scripts/dev-server-scanner.cjs +22 -8
  105. package/dist-lib/sdk.cjs +27 -11
  106. package/dist-lib/sdk.mjs +27 -10
  107. package/dist-lib/skills.cjs +72201 -26208
  108. package/dist-lib/swift-compile.cjs +1007 -140
  109. package/dist-lib/swift-compile.mjs +1049 -174
  110. package/dist-lib/swift-package.cjs +12 -3
  111. package/dist-lib/swift-package.mjs +12 -3
  112. package/dist-lib/swift-submit.cjs +13 -5
  113. package/dist-lib/swift-submit.mjs +11 -5
  114. package/dist-lib/swift-toolchain.cjs +423 -33
  115. package/dist-lib/swift.cjs +1 -1
  116. package/dist-lib/vite-swift.cjs +8628 -5268
  117. package/dist-lib/vite.cjs +2859 -12
  118. package/dist-lib/worklet-executable.cjs +14 -2
  119. package/dist-lib/worklet-executable.mjs +14 -2
  120. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  121. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  122. package/package.json +1 -1
  123. package/scripts/dev-server-scanner.ts +2 -1
  124. package/skills/peach-perf/SKILL.md +11 -5
  125. package/skills/peach-setup/SKILL.md +16 -19
  126. package/src/backend-origin.ts +13 -6
  127. package/src/bridge-constants.ts +11 -0
  128. package/src/bridge-contract.ts +22 -34
  129. package/src/config.ts +15 -0
  130. package/src/desktop-host-delivery.ts +5 -1
  131. package/src/dev-bundle-resolution.ts +2 -1
  132. package/src/expo-manifest-body.ts +13 -0
  133. package/src/expo-swift-host.ts +196 -49
  134. package/src/expo-swift-ready.ts +78 -0
  135. package/src/expoKotlinContract.ts +4 -7
  136. package/src/expoSwiftContract.ts +17 -6
  137. package/src/home-paths.ts +48 -2
  138. package/src/host/bridge-host.ts +5 -2
  139. package/src/host/staged-host-files.ts +9 -0
  140. package/src/native-app-identity-project.ts +27 -6
  141. package/src/native-app-identity.ts +91 -4
  142. package/src/native-firebase-config.ts +209 -13
  143. package/src/nativeAppIconContract.ts +1 -4
  144. package/src/output-lock-stdio.ts +36 -0
  145. package/src/process-group.ts +25 -2
  146. package/src/react-native-bridge-modules.ts +431 -0
  147. package/src/react-native-host-modules.ts +8 -1
  148. package/src/settings-bundle.ts +1 -4
  149. package/src/swift-binary-modules.ts +2 -2
  150. package/src/swift-compile.ts +918 -109
  151. package/src/swift-compiler-digest.ts +14 -0
  152. package/src/swift-distribution.lock.json +24 -12
  153. package/src/swift-distribution.ts +103 -4
  154. package/src/swift-host.ts +180 -55
  155. package/src/swift-icu.ts +27 -13
  156. package/src/swift-objc-compile.ts +355 -0
  157. package/src/swift-objc-sdk.ts +821 -27
  158. package/src/swift-objc-standin.ts +148 -14
  159. package/src/swift-package-manifest.ts +60 -7
  160. package/src/swift-package.ts +15 -1
  161. package/src/swift-packages.ts +154 -81
  162. package/src/swift-standin-emit.ts +96 -15
  163. package/src/swift-standins.ts +530 -116
  164. package/src/swift-submit.ts +13 -5
  165. package/src/swift-toolchain.ts +500 -42
  166. package/src/vite-plugin.ts +12 -1
  167. package/src/worker-system-chunks.ts +11 -12
  168. package/src/worklet-executable.ts +19 -1
  169. package/src/xcode-project.ts +270 -30
@@ -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)
@@ -469,7 +476,10 @@ function packageRow(
469
476
  return {
470
477
  rank: RANK.unknown,
471
478
  glyph: accent('?'),
472
- 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',
473
483
  tone: muted,
474
484
  }
475
485
  // a stub answers ahead of Peach's own support for the package
@@ -553,19 +563,24 @@ function printNativeGuidance(
553
563
  )
554
564
  }
555
565
  // the open names grouped by the package they belong to, the key `packages` takes
556
- const open = new Map<string, NativeRequest[]>()
566
+ const open = new Map<string, Map<string, NativeRequest>>()
557
567
  for (const [owner, requests] of [
558
568
  ...unknownNative.map((item) => [item.name, item.requests] as const),
559
569
  ...local.map((item) => [item.package, [item]] as const),
560
570
  ]) {
561
- const unanswered = requests.filter((request) => !request.answered)
562
- 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
+ }
563
577
  }
564
578
  if (open.size) {
565
579
  console.log(`\n stub them in peach.config.ts, by package:\n`)
566
580
  console.log(muted(` export default defineConfig({`))
567
581
  console.log(muted(` packages: {`))
568
- for (const [owner, requests] of open) {
582
+ for (const [owner, requestsByName] of open) {
583
+ const requests = [...requestsByName.values()]
569
584
  console.log(muted(` '${owner}': {`))
570
585
  for (const kind of ['module', 'view'] as const) {
571
586
  const names = requests.filter((request) => request.kind === kind)
@@ -817,23 +832,6 @@ function findUnknownNative(
817
832
  })
818
833
  }
819
834
 
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
835
  function reportLocalNative(local: readonly LocalNative[]): void {
838
836
  for (const item of local) {
839
837
  const where = item.expoModule
@@ -845,26 +843,6 @@ function reportLocalNative(local: readonly LocalNative[]): void {
845
843
  }
846
844
  }
847
845
 
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
846
  function answeredNames(
869
847
  config: Awaited<ReturnType<typeof resolveRNXAppConfig>>['config'],
870
848
  ): AnsweredNames {
@@ -878,143 +856,6 @@ function answeredNames(
878
856
  }
879
857
  }
880
858
 
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
859
  function scanOnePackage(
1019
860
  appDir: string,
1020
861
  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, '--live-bundle')
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
 
@@ -422,9 +422,15 @@ export async function waitForSootsimIdle({
422
422
  webViewStarting = true
423
423
  webViewBlocker = 'preview progress element displayed'
424
424
  } else {
425
- const unreadyWebView = document.querySelector(
425
+ // a loading document with no area (an app's hidden worker or
426
+ // executor frame) paints nothing, and a screenshot on device
427
+ // does not wait for it.
428
+ const unreadyWebView = [...document.querySelectorAll(
426
429
  '[data-sootsim-webview-loading="true"], [data-sootsim-webview-mode="placeholder"]'
427
- )
430
+ )].find((view) => {
431
+ const frame = view.querySelector('iframe')
432
+ return !frame || frame.clientWidth > 0 || frame.clientHeight > 0
433
+ })
428
434
  if (unreadyWebView) {
429
435
  webViewStarting = true
430
436
  webViewBlocker = 'webView document still loading'
@@ -691,6 +697,9 @@ export type SettledWaitReady = {
691
697
  // the last readiness read: the settled tree once the app went idle,
692
698
  // otherwise the read that ended the wait.
693
699
  status: WaitReadyStatus
700
+ // the first read that counted as ready, before the settle: the probe the
701
+ // wait accepted. null when the app never became ready.
702
+ accepted: WaitReadyStatus | null
694
703
  // the last settle; null when the app never became ready.
695
704
  idle: WaitForSootsimIdleResult | null
696
705
  elapsedMs: number
@@ -709,8 +718,10 @@ export async function waitReadyAndSettle(
709
718
  ): Promise<SettledWaitReady> {
710
719
  const start = Date.now()
711
720
  const remaining = () => Math.max(0, start + timeoutMs - Date.now())
721
+ let accepted: WaitReadyStatus | null = null
712
722
  const result = (status: WaitReadyStatus, idle: WaitForSootsimIdleResult | null) => ({
713
723
  status,
724
+ accepted,
714
725
  idle,
715
726
  elapsedMs: Date.now() - start,
716
727
  })
@@ -719,6 +730,7 @@ export async function waitReadyAndSettle(
719
730
  idle = null
720
731
  const ready = await inspectWaitReady(bridge, remaining(), options)
721
732
  if (!ready.ready) return result(ready, idle)
733
+ accepted = ready
722
734
  idle = await waitForSootsimIdle({
723
735
  bridge,
724
736
  simId: options.simId,
@@ -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) {
@@ -26,10 +26,12 @@ export async function runWaitReadySubcommand(opts: {
26
26
  if (bridge.plane === 'cloud') {
27
27
  const status = await inspectWaitReady(bridge, timeoutMs)
28
28
  if (!status.ready) timedOut(status, status.elapsedMs)
29
+ printAcceptedProbe(status)
29
30
  printReady(status, status.elapsedMs)
30
31
  return
31
32
  }
32
33
  let status: Awaited<ReturnType<typeof waitReadyAndSettle>>['status']
34
+ let accepted: Awaited<ReturnType<typeof waitReadyAndSettle>>['accepted']
33
35
  let idle: Awaited<ReturnType<typeof waitReadyAndSettle>>['idle']
34
36
  let elapsedMs: number
35
37
  try {
@@ -41,6 +43,7 @@ export async function runWaitReadySubcommand(opts: {
41
43
  },
42
44
  })
43
45
  status = settledWait.status
46
+ accepted = settledWait.accepted
44
47
  idle = settledWait.idle
45
48
  elapsedMs = settledWait.elapsedMs
46
49
  } catch (error) {
@@ -51,6 +54,7 @@ export async function runWaitReadySubcommand(opts: {
51
54
  throw error
52
55
  }
53
56
  if (!idle) timedOut(status, elapsedMs)
57
+ if (accepted) printAcceptedProbe(accepted)
54
58
  if (!idle.settled) {
55
59
  console.error(
56
60
  ` ⚠ wait ready timed out after ${elapsedMs}ms — app mounted but did not settle: ${idle.blockedBy} (nodes: ${status.nodes}, targets: ${status.targets})`,
@@ -66,6 +70,14 @@ export async function runWaitReadySubcommand(opts: {
66
70
  printReady(status, elapsedMs)
67
71
  }
68
72
 
73
+ // the probe the wait accepted, kept so a ready that later proves wrong can be
74
+ // read from the command's own output.
75
+ function printAcceptedProbe(status: WaitReadyStatus): void {
76
+ console.error(
77
+ ` accepted probe at ${Math.round(status.elapsedMs)}ms: basis ${status.readyBasis}, flag ${String(status.flag)}, ${status.nodes} nodes (quiet ${Math.round(status.treeQuietMs)}ms), ${status.targets} targets, painted ${status.paintedAppContent}, live frame ${status.liveFrameActive} (${status.liveFrameChannels} channels, ${status.liveFramePublishes} publishes), browser surfaces ${status.browserSurfaceCount}`,
78
+ )
79
+ }
80
+
69
81
  function printReady(status: WaitReadyStatus, elapsedMs: number): void {
70
82
  console.log(
71
83
  ` ready in ${elapsedMs}ms: ${status.nodes} nodes, ${status.targets} targets${status.liveFrameActive ? `, ${status.liveFrameChannels} live surfaces` : ''}${status.browserSurfaceCount ? `, ${status.browserSurfaceCount} browser surfaces` : ''}`,