peachsim 0.1.1625 → 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 (152) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +10 -1
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +64 -43
  7. package/cli/command-registry.ts +1 -0
  8. package/cli/commands/control.ts +283 -49
  9. package/cli/commands/doctor.ts +17 -184
  10. package/cli/commands/flow.ts +79 -11
  11. package/cli/commands/inspect/core.ts +10 -5
  12. package/cli/commands/inspect/settling.ts +14 -2
  13. package/cli/commands/inspect/wait-ready.ts +12 -0
  14. package/cli/commands/inspect.ts +26 -3
  15. package/cli/commands/install-desktop.ts +1 -0
  16. package/cli/commands/maestro-compare.ts +97 -38
  17. package/cli/commands/maestro.ts +9 -1
  18. package/cli/commands/record.ts +21 -1
  19. package/cli/commands/screenshot.ts +23 -1
  20. package/cli/commands/serve.ts +7 -2
  21. package/cli/commands/setup.ts +19 -42
  22. package/cli/commands/swift.ts +5 -0
  23. package/cli/commands/upload.ts +46 -7
  24. package/cli/desktop-companion.ts +102 -17
  25. package/cli/drivers/desktop.ts +14 -1
  26. package/cli/drivers/playwright-sim-host.ts +38 -3
  27. package/cli/drivers/playwright.ts +9 -8
  28. package/cli/drivers/types.ts +5 -0
  29. package/cli/maestro-accessibility.ts +1 -1
  30. package/cli/maestro-compare.ts +84 -12
  31. package/cli/main.ts +2 -1
  32. package/cli/outbound-endpoints.ts +13 -1
  33. package/cli/pause-aware-timeout.ts +102 -0
  34. package/cli/scene-video.ts +89 -0
  35. package/cli/ws-bridge.ts +23 -14
  36. package/detox/attempt-events.cjs +4 -0
  37. package/detox/index.ts +167 -142
  38. package/detox/motion-change.cjs +57 -21
  39. package/detox/motion-change.d.cts +7 -2
  40. package/dist-lib/agent-daemon-client.cjs +1 -1
  41. package/dist-lib/agent-events.cjs +1 -1
  42. package/dist-lib/attached-projects.cjs +1 -1
  43. package/dist-lib/auth/shared-session.cjs +1 -1
  44. package/dist-lib/backend-origin.cjs +1 -1
  45. package/dist-lib/beta.cjs +1 -1
  46. package/dist-lib/beta.mjs +1 -1
  47. package/dist-lib/bridge-constants.cjs +13 -1
  48. package/dist-lib/bridge-contract-input.cjs +1 -1
  49. package/dist-lib/bridge-contract-input.mjs +1 -1
  50. package/dist-lib/bridge-contract.cjs +14 -8
  51. package/dist-lib/bridge-contract.mjs +13 -7
  52. package/dist-lib/bundle-channel.cjs +9536 -7604
  53. package/dist-lib/capture-contract.cjs +1 -1
  54. package/dist-lib/capture-contract.mjs +1 -1
  55. package/dist-lib/cloud-contract.cjs +1 -1
  56. package/dist-lib/cloud-contract.mjs +1 -1
  57. package/dist-lib/cloud-test-args.cjs +1 -1
  58. package/dist-lib/cloud-test-args.mjs +1 -1
  59. package/dist-lib/cloud.cjs +1 -1
  60. package/dist-lib/cloud.mjs +1 -1
  61. package/dist-lib/config.cjs +6 -3
  62. package/dist-lib/config.mjs +6 -3
  63. package/dist-lib/detox/index.cjs +895 -664
  64. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  65. package/dist-lib/expo-swift-bundle.cjs +7818 -5916
  66. package/dist-lib/expo-swift-metadata.cjs +3 -1
  67. package/dist-lib/expo-swift-metadata.mjs +3 -1
  68. package/dist-lib/home-paths.cjs +32 -3
  69. package/dist-lib/host/bridge-host.cjs +8340 -6392
  70. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  71. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  72. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  73. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  74. package/dist-lib/host/websocket-proxy.cjs +1 -1
  75. package/dist-lib/index.cjs +812 -339
  76. package/dist-lib/jump-to-source-babel.cjs +1 -1
  77. package/dist-lib/jump-to-source-native.cjs +1 -1
  78. package/dist-lib/kotlin-host.cjs +1 -1
  79. package/dist-lib/menu.cjs +1 -1
  80. package/dist-lib/menu.mjs +1 -1
  81. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  82. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  83. package/dist-lib/metro-production-bundle.cjs +1 -1
  84. package/dist-lib/metro-production-bundle.mjs +1 -1
  85. package/dist-lib/metro.cjs +391 -28
  86. package/dist-lib/native-app-identity-project.cjs +55 -2
  87. package/dist-lib/native-app-identity-project.mjs +55 -2
  88. package/dist-lib/native-app-identity.cjs +53 -1
  89. package/dist-lib/native-app-identity.mjs +52 -1
  90. package/dist-lib/native-firebase-metadata.cjs +1 -1
  91. package/dist-lib/native-firebase-metadata.mjs +1 -1
  92. package/dist-lib/plist.cjs +1 -1
  93. package/dist-lib/plist.mjs +1 -1
  94. package/dist-lib/profiles.cjs +1 -1
  95. package/dist-lib/public-brand.cjs +1 -1
  96. package/dist-lib/react-native-host-modules.cjs +5 -1
  97. package/dist-lib/react-native-host-modules.mjs +5 -1
  98. package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
  99. package/dist-lib/sdk.cjs +27 -11
  100. package/dist-lib/sdk.mjs +27 -10
  101. package/dist-lib/skills.cjs +12276 -8947
  102. package/dist-lib/swift-compile.cjs +924 -118
  103. package/dist-lib/swift-compile.mjs +966 -152
  104. package/dist-lib/swift-package.cjs +12 -3
  105. package/dist-lib/swift-package.mjs +12 -3
  106. package/dist-lib/swift-submit.cjs +7 -1
  107. package/dist-lib/swift-submit.mjs +5 -1
  108. package/dist-lib/swift-toolchain.cjs +133 -18
  109. package/dist-lib/swift.cjs +1 -1
  110. package/dist-lib/vite-swift.cjs +1955 -498
  111. package/dist-lib/vite.cjs +3 -1
  112. package/dist-lib/worklet-executable.cjs +1 -1
  113. package/dist-lib/worklet-executable.mjs +1 -1
  114. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  115. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  116. package/package.json +1 -1
  117. package/scripts/dev-server-scanner.ts +2 -1
  118. package/skills/peach-perf/SKILL.md +11 -5
  119. package/skills/peach-setup/SKILL.md +16 -19
  120. package/src/bridge-constants.ts +11 -0
  121. package/src/bridge-contract.ts +22 -34
  122. package/src/config.ts +15 -0
  123. package/src/desktop-host-delivery.ts +5 -1
  124. package/src/dev-bundle-resolution.ts +2 -1
  125. package/src/expo-manifest-body.ts +13 -0
  126. package/src/expo-swift-host.ts +191 -47
  127. package/src/expo-swift-ready.ts +78 -0
  128. package/src/expoSwiftContract.ts +13 -0
  129. package/src/home-paths.ts +48 -2
  130. package/src/host/bridge-host.ts +5 -2
  131. package/src/native-app-identity-project.ts +26 -2
  132. package/src/native-app-identity.ts +90 -0
  133. package/src/native-firebase-config.ts +209 -13
  134. package/src/process-group.ts +25 -2
  135. package/src/react-native-bridge-modules.ts +416 -0
  136. package/src/react-native-host-modules.ts +4 -0
  137. package/src/swift-binary-modules.ts +2 -2
  138. package/src/swift-compile.ts +835 -84
  139. package/src/swift-compiler-digest.ts +14 -0
  140. package/src/swift-distribution.lock.json +14 -12
  141. package/src/swift-host.ts +91 -29
  142. package/src/swift-icu.ts +14 -6
  143. package/src/swift-objc-sdk.ts +133 -10
  144. package/src/swift-objc-standin.ts +25 -3
  145. package/src/swift-package-manifest.ts +21 -6
  146. package/src/swift-package.ts +15 -1
  147. package/src/swift-packages.ts +119 -60
  148. package/src/swift-standin-emit.ts +57 -9
  149. package/src/swift-standins.ts +168 -20
  150. package/src/swift-submit.ts +10 -0
  151. package/src/swift-toolchain.ts +149 -23
  152. package/src/vite-plugin.ts +12 -1
@@ -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
 
@@ -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,
@@ -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` : ''}`,
@@ -34,6 +34,7 @@ import {
34
34
  parseBridgeCliArgs,
35
35
  printBridgeWorldNotice,
36
36
  type WsBridge,
37
+ type BridgeSimInfo,
37
38
  } from '../ws-bridge'
38
39
  import {
39
40
  isTapSuccess,
@@ -596,13 +597,13 @@ export function printShellPerfReport(result: Record<string, any>) {
596
597
  console.log(``)
597
598
  console.log(` cadence (frame delivery, ${cadence.vsyncTicks} vsync ticks):`)
598
599
  console.log(
599
- ` display clock: ${cadence.displayHz}hz · host rAF interval p50 ${cadence.hostIntervalP50} / p95 ${cadence.hostIntervalP95} / max ${cadence.hostIntervalMax} ms${cadence.hostGaps ? ` · ${cadence.hostGaps} gaps >1.5x (host rAF starved)` : ''}`,
600
+ ` display clock: ${cadence.displayHz}hz · host rAF interval p50 ${cadence.hostIntervalP50} / p95 ${cadence.hostIntervalP95} / max ${cadence.hostIntervalMax} ms${cadence.hostGaps ? ` · ${cadence.hostGaps} gaps >1.5x (host rAF starved)` : ''}${cadence.idleBreaks ? ` · ${cadence.idleBreaks} host idle breaks excluded` : ''}`,
600
601
  )
601
602
  console.log(
602
603
  ` delivery lag: p50 ${cadence.deliveryLagP50} / p95 ${cadence.deliveryLagP95} / max ${cadence.deliveryLagMax} ms (vsync postMessage → shell receipt)`,
603
604
  )
604
605
  console.log(
605
- ` paint interval: p50 ${cadence.paintIntervalP50} / p95 ${cadence.paintIntervalP95} / max ${cadence.paintIntervalMax} ms${cadence.idleBreaks ? ` · ${cadence.idleBreaks} idle breaks excluded` : ''}`,
606
+ ` present interval: p50 ${cadence.paintIntervalP50} / p95 ${cadence.paintIntervalP95} / max ${cadence.paintIntervalMax} ms (during motion, ${cadence.paintIntervals} intervals, including native frames)`,
606
607
  )
607
608
  }
608
609
  const compositorCadence = result.compositorCadence
@@ -1247,7 +1248,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1247
1248
  return
1248
1249
  }
1249
1250
 
1250
- // `peach list --drivers` — pure local registry view, no bridge needed.
1251
+ // driver discovery works offline; a live bridge also reports each running renderer.
1251
1252
  // short-circuit here so users can discover drivers even when no peach
1252
1253
  // session is running.
1253
1254
  if (
@@ -1256,6 +1257,19 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1256
1257
  ) {
1257
1258
  const { buildDriverListRows } = await import('../drivers')
1258
1259
  const rows = buildDriverListRows()
1260
+ const bridge = (opts.createBridge ?? createBridgeFromParsed)(parsed)
1261
+ let sims: BridgeSimInfo[] = []
1262
+ try {
1263
+ sims = await bridge.listSims()
1264
+ } catch {
1265
+ // local availability remains useful when no bridge is running.
1266
+ } finally {
1267
+ bridge.close()
1268
+ }
1269
+ if (wantsJson(effectiveArgs)) {
1270
+ printJson({ drivers: rows, sims })
1271
+ return
1272
+ }
1259
1273
  console.log(` available drivers (${rows.length}):\n`)
1260
1274
  const idWidth = Math.max(...rows.map((r) => r.id.length), 6)
1261
1275
  const kindWidth = Math.max(...rows.map((r) => r.kind.length), 4)
@@ -1270,6 +1284,15 @@ export async function runInspect(args: string[], opts: InspectOptions) {
1270
1284
  console.log(` unavailable: ${row.reason}`)
1271
1285
  }
1272
1286
  }
1287
+ for (const sim of sims) {
1288
+ const renderer = sim.meta?.peachRenderer
1289
+ const executable = sim.meta?.peachHostExecutable
1290
+ if (sim.readyState === 'open' && typeof renderer === 'string') {
1291
+ console.log(
1292
+ ` in use: ${sim.id} · ${String(sim.meta?.sootsimHostDriver)} · ${renderer}${typeof executable === 'string' ? ` @ ${executable}` : ''}`,
1293
+ )
1294
+ }
1295
+ }
1273
1296
  return
1274
1297
  }
1275
1298
 
@@ -126,6 +126,7 @@ async function installLinuxDeb(packagePath: string): Promise<string> {
126
126
  async function installWindowsExe(exePath: string): Promise<string> {
127
127
  // nsis installer prompts and self-elevates; run it and let it take over.
128
128
  await new Promise<void>((resolvePromise, rejectPromise) => {
129
+ // runner-toolchain-platform: win32
129
130
  const child = spawn('cmd', ['/c', 'start', '""', '/wait', exePath], {
130
131
  stdio: 'inherit',
131
132
  })