peachsim 0.1.626 → 0.1.628

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 (106) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +3 -2
  3. package/cli/bin.ts +6 -2
  4. package/cli/bridge-flow-runner.ts +79 -72
  5. package/cli/commands/box/bundle-plane.ts +7 -2
  6. package/cli/commands/box.ts +9 -2
  7. package/cli/commands/cleanup.ts +6 -6
  8. package/cli/commands/control.ts +36 -6
  9. package/cli/commands/detox.ts +25 -11
  10. package/cli/commands/flow.ts +20 -8
  11. package/cli/commands/inspect.ts +22 -37
  12. package/cli/commands/maestro-generate.ts +5 -12
  13. package/cli/commands/maestro.ts +16 -6
  14. package/cli/commands/mode.ts +11 -7
  15. package/cli/commands/platform.ts +1 -0
  16. package/cli/commands/report-issue.ts +1 -1
  17. package/cli/commands/setup.ts +16 -11
  18. package/cli/commands/show-touches-mode.ts +33 -0
  19. package/cli/commands/skills.ts +16 -0
  20. package/cli/commands/test.ts +22 -1
  21. package/cli/commands/upload.ts +18 -8
  22. package/cli/commands/welcome.ts +3 -3
  23. package/cli/drivers/playwright-sim-host.ts +16 -8
  24. package/cli/drivers/playwright.ts +8 -5
  25. package/cli/help-core.ts +9 -4
  26. package/cli/internal-child.ts +14 -2
  27. package/cli/levenshtein.ts +27 -0
  28. package/cli/main.ts +28 -18
  29. package/cli/run-rnx.ts +8 -4
  30. package/cli/ws-bridge.ts +3 -2
  31. package/detox/index.ts +234 -7
  32. package/dist-lib/agent-daemon-client.cjs +2 -1
  33. package/dist-lib/agent-events.cjs +1 -1
  34. package/dist-lib/agent-identity.cjs +1 -1
  35. package/dist-lib/agent-sessions.cjs +1 -1
  36. package/dist-lib/attached-projects.cjs +1 -1
  37. package/dist-lib/auth/shared-session.cjs +1 -1
  38. package/dist-lib/backend-origin.cjs +1 -1
  39. package/dist-lib/beta.cjs +1 -1
  40. package/dist-lib/beta.mjs +1 -1
  41. package/dist-lib/bridge-constants.cjs +4 -1
  42. package/dist-lib/bridge-contract-input.cjs +1 -1
  43. package/dist-lib/bridge-contract-input.mjs +1 -1
  44. package/dist-lib/bridge-contract.cjs +1 -1
  45. package/dist-lib/bridge-contract.mjs +1 -1
  46. package/dist-lib/capture-contract.cjs +18 -8
  47. package/dist-lib/capture-contract.mjs +18 -8
  48. package/dist-lib/cli-constants.cjs +1 -1
  49. package/dist-lib/cloud-contract.cjs +1 -1
  50. package/dist-lib/cloud-contract.mjs +1 -1
  51. package/dist-lib/cloud-test-args.cjs +1 -1
  52. package/dist-lib/cloud-test-args.mjs +1 -1
  53. package/dist-lib/cloud.cjs +1 -1
  54. package/dist-lib/cloud.mjs +1 -1
  55. package/dist-lib/config.cjs +1 -1
  56. package/dist-lib/detox/index.cjs +170 -13
  57. package/dist-lib/dev-bundle-resolution.cjs +2 -2
  58. package/dist-lib/home-paths.cjs +1 -1
  59. package/dist-lib/host/bridge-host.cjs +280 -138
  60. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  61. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  62. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  63. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  64. package/dist-lib/host/websocket-proxy.cjs +1 -1
  65. package/dist-lib/index.cjs +62 -12
  66. package/dist-lib/jump-to-source-babel.cjs +1 -1
  67. package/dist-lib/jump-to-source-native.cjs +1 -1
  68. package/dist-lib/menu.cjs +7 -1
  69. package/dist-lib/menu.mjs +7 -1
  70. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  71. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  72. package/dist-lib/metro-production-bundle.cjs +1 -1
  73. package/dist-lib/metro-production-bundle.mjs +1 -1
  74. package/dist-lib/metro.cjs +43 -4
  75. package/dist-lib/profiles.cjs +1 -1
  76. package/dist-lib/public-brand.cjs +1 -1
  77. package/dist-lib/react-native-host-modules.cjs +1 -1
  78. package/dist-lib/react-native-host-modules.mjs +1 -1
  79. package/dist-lib/render-mode.cjs +1 -1
  80. package/dist-lib/scripts/dev-server-scanner.cjs +215 -28
  81. package/dist-lib/sdk.cjs +1 -1
  82. package/dist-lib/sdk.mjs +1 -1
  83. package/dist-lib/skills.cjs +431 -276
  84. package/dist-lib/swift-compile.cjs +1 -1
  85. package/dist-lib/swift-compile.mjs +1 -1
  86. package/dist-lib/swift-package.cjs +1 -1
  87. package/dist-lib/swift-package.mjs +1 -1
  88. package/dist-lib/swift-submit.cjs +1 -1
  89. package/dist-lib/swift-submit.mjs +1 -1
  90. package/dist-lib/swift.cjs +14512 -14208
  91. package/dist-lib/vite-kotlin.cjs +1 -1
  92. package/dist-lib/vite-swift.cjs +1 -1
  93. package/dist-lib/vite.cjs +44 -4
  94. package/install.mjs +16 -0
  95. package/install.sh +199 -0
  96. package/package.json +6 -1
  97. package/scripts/dev-server-scanner.ts +77 -46
  98. package/skills/contrast/SKILL.md +1 -1
  99. package/src/bridge-constants.ts +11 -0
  100. package/src/capture-contract.ts +40 -7
  101. package/src/dev-bundle-resolution.ts +1 -1
  102. package/src/host/bridge-host.ts +26 -137
  103. package/src/menu.ts +7 -0
  104. package/src/native-seam-manifest.ts +4 -16
  105. package/src/runtime-assets.ts +59 -2
  106. package/src/vite-plugin.ts +723 -25
@@ -17,6 +17,7 @@ import { getCliIdentityKey } from '../current-sim'
17
17
  import { rememberFlowCandidate } from '../flow-session'
18
18
  import { maybeHint } from '../hints'
19
19
  import { shouldPrintInspectNotice } from '../inspect-notice-state'
20
+ import { levenshtein } from '../levenshtein'
20
21
  import { TOP_LEVEL_RUNTIME_COMMANDS } from '../parse-args'
21
22
  import { rethrowIfExit, rnxExit } from '../run-rnx'
22
23
  import {
@@ -243,34 +244,6 @@ function scoreIdSimilarity(needle: string, candidate: string): number {
243
244
  return levenshtein(needle, candidate) - prefix
244
245
  }
245
246
 
246
- function levenshtein(a: string, b: string): number {
247
- if (a === b) return 0
248
- if (!a.length) return b.length
249
- if (!b.length) return a.length
250
- // standard two-row dp: prev[] is the previous row, cur[] is the row being
251
- // computed; swap pointers per outer iteration. the earlier in-place rolling
252
- // version overwrote prev[j-1] twice per cell and never set prev[0]=i, so
253
- // distance(`kitten`,`sitting`) returned 8 instead of 3 and the fuzzy-suggest
254
- // ranking was effectively random.
255
- let prev = new Array<number>(b.length + 1)
256
- let cur = new Array<number>(b.length + 1)
257
- for (let j = 0; j <= b.length; j++) prev[j] = j
258
- for (let i = 1; i <= a.length; i++) {
259
- cur[0] = i
260
- for (let j = 1; j <= b.length; j++) {
261
- cur[j] = Math.min(
262
- prev[j] + 1,
263
- cur[j - 1] + 1,
264
- prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
265
- )
266
- }
267
- const tmp = prev
268
- prev = cur
269
- cur = tmp
270
- }
271
- return prev[b.length]
272
- }
273
-
274
247
  function formatNetworkStatus(e: NetworkEntryPayload): string {
275
248
  if (e.error) return 'err'
276
249
  if (e.status == null) return ' … '
@@ -3539,7 +3512,16 @@ export async function runInspect(args: string[], opts: InspectOptions) {
3539
3512
  if (reloadResult && reloadResult.engineStale) {
3540
3513
  console.log(' engine build changed since page load — full page reload')
3541
3514
  }
3542
- } catch {
3515
+ } catch (error) {
3516
+ // no sim at all is not a reload in flight: report it rather than
3517
+ // waiting out the reconnect budget.
3518
+ const message = error instanceof Error ? error.message : String(error)
3519
+ if (
3520
+ message === 'no sim connected' ||
3521
+ message.startsWith('could not connect to ws://')
3522
+ ) {
3523
+ throw error
3524
+ }
3543
3525
  // the sim may already be in the middle of reloading; fall through to reconnect wait
3544
3526
  }
3545
3527
  console.log(' reloading...')
@@ -3623,7 +3605,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
3623
3605
  // ignore errors checking for errors
3624
3606
  }
3625
3607
  }
3626
- if (readyResult && !readyResult.ready) {
3608
+ if (!readyResult?.ready) {
3627
3609
  rnxExit(1)
3628
3610
  }
3629
3611
  break
@@ -4655,15 +4637,18 @@ export async function runInspect(args: string[], opts: InspectOptions) {
4655
4637
  // single 15s timeout into ~60s of apparent hang (QA F19-1). there is
4656
4638
  // nothing useful to fetch from a sim that just timed out, so skip them.
4657
4639
  const timedOut = /^command timed out after (\d+)s$/.exec(message)
4658
- // a disconnected / never-connected bridge is genuinely gone; recover is
4659
- // right and probing it would just re-incur a full timeout.
4640
+ // a disconnected bridge is genuinely gone; recover is right and probing
4641
+ // it would just re-incur a full timeout. no bridge at all means there is
4642
+ // no sim to recover, only one to open.
4660
4643
  const disconnected =
4661
4644
  message.startsWith('sim disconnected:') ||
4662
- message.startsWith('bridge never reconnected') ||
4663
- message.startsWith('could not connect to ws://')
4645
+ message.startsWith('bridge never reconnected')
4664
4646
  if (unknownSim) {
4665
4647
  await printUnknownSimHint(bridge, wsPort, unknownSim[1])
4666
- } else if (/^no sim connected$/.test(message)) {
4648
+ } else if (
4649
+ /^no sim connected$/.test(message) ||
4650
+ message.startsWith('could not connect to ws://')
4651
+ ) {
4667
4652
  printMissingSimHint(wsPort)
4668
4653
  } else if (timedOut) {
4669
4654
  // a command timeout alone does not mean a wedged sim — a large screen
@@ -4677,9 +4662,9 @@ export async function runInspect(args: string[], opts: InspectOptions) {
4677
4662
  process.stderr.write(
4678
4663
  ` the sim is still responsive — '${cmd}' just exceeded the ${timedOut[1]}s command budget.\n` +
4679
4664
  ` the screen's node tree is large; narrow the query or raise the budget:\n` +
4680
- ` peach --testid <id> # scope to one subtree\n` +
4665
+ ` peach ${cmd} --subtree <id> # scope to one subtree\n` +
4681
4666
  ` peach find --testid <id> # targeted single-node lookup\n` +
4682
- ` peach --timeout 60000 # raise per-command budget (ms)\n`,
4667
+ ` peach ${cmd} --timeout 60000 # raise per-command budget (ms)\n`,
4683
4668
  )
4684
4669
  } else {
4685
4670
  printRecoverHint()
@@ -4,6 +4,7 @@ import { mkdtempSync, writeFileSync } from 'fs'
4
4
  import { tmpdir } from 'os'
5
5
  import { join } from 'path'
6
6
  import yaml from 'yaml'
7
+ import { isRecord } from '../../src/is-record'
7
8
  import { authHeaderValue, resolveCliAuth } from '../auth'
8
9
  import { registerRun, stepSummaryFromTrace } from '../run-registry'
9
10
  import { createBridgeFromParsed, parseBridgeCliArgs } from '../ws-bridge'
@@ -18,7 +19,6 @@ import {
18
19
  } from './flow'
19
20
  import { inspectDescribe } from './inspect/core'
20
21
  import { resolveDefaultUploadOrigin } from './upload'
21
- import { isRecord } from '../../src/is-record'
22
22
 
23
23
  const DEFAULT_TEST_MODEL = process.env.PEACH_TEST_MODEL || 'deepseek-v4-flash'
24
24
  const DESCRIBE_CONTEXT_LIMIT = 18_000
@@ -239,14 +239,7 @@ export function buildFlowArgs(
239
239
  flowPath: string,
240
240
  origin: string,
241
241
  ) {
242
- const out = [
243
- flowPath,
244
- '--preview',
245
- '--preview-origin',
246
- origin,
247
- '--billing-kind',
248
- 'test_run',
249
- ]
242
+ const out = [flowPath, '--preview', '--preview-origin', origin]
250
243
  for (let i = 0; i < args.length; i++) {
251
244
  if (i === promptIndex) continue
252
245
  const arg = args[i]
@@ -285,10 +278,10 @@ export function findUnexpectedGenerateArgs(
285
278
  if (i === promptIndex) continue
286
279
  const arg = args[i]
287
280
  if (!arg) continue
288
- // generate writes --billing-kind onto the flow argv itself and getFlag
289
- // takes the first occurrence, so a caller's value loses to it in silence.
281
+ // a generated flow's recording is an ordinary preview upload; the only
282
+ // billing kind is a hosted test job's, which the recorder sets.
290
283
  if (arg === '--billing-kind') {
291
- problems.push({ arg, message: `${arg} is set by peach maestro generate` })
284
+ problems.push({ arg, message: `${arg} is only for hosted test jobs` })
292
285
  i += 1
293
286
  continue
294
287
  }
@@ -26,6 +26,7 @@ import { RnxExit } from '../run-rnx'
26
26
  import {
27
27
  findUnexpectedFlowArgs,
28
28
  FLOW_ARG_VALUE_FLAGS,
29
+ getLastFlowPreviewUploadError,
29
30
  getLastFlowPreviewUploadResult,
30
31
  getLastFlowTraceSteps,
31
32
  hoistLeadingSimFlag,
@@ -728,6 +729,10 @@ export async function runMaestro(
728
729
  console.error(` error: ${target} not found`)
729
730
  return 1
730
731
  }
732
+ // cwd is the real path, so name every flow from the target's real path too:
733
+ // a target under a symlink (macOS /tmp, /var/folders) otherwise reports as
734
+ // ../../../var/folders/... instead of flows/login.yaml.
735
+ target = fs.realpathSync(target)
731
736
 
732
737
  let files: string[]
733
738
  let workspaceConfig: MaestroWorkspaceConfig = {}
@@ -886,24 +891,29 @@ export async function runMaestro(
886
891
  // the upload failed, so the flow never disappears from the collected
887
892
  // entries.
888
893
  if (args.includes('--preview')) {
894
+ const uploaded = Boolean(upload?.shareId || upload?.previewUrl)
889
895
  const previewPayload = {
890
896
  flow: path.relative(cwd, file) || file,
891
897
  previewId: upload?.shareId ?? null,
892
898
  previewUrl: upload?.previewUrl ?? null,
893
- // the failing step as the junit report names it, on one line
899
+ // the failing step as the junit report names it, on one line, else
900
+ // why the required recording upload failed
894
901
  failure:
895
- exit === 0
902
+ exit === 0 && uploaded
896
903
  ? null
897
- : failureFromTraceSteps(traceSteps, 'flow playback failed').body.split(
898
- '\n',
899
- )[0],
904
+ : failureFromTraceSteps(
905
+ traceSteps,
906
+ uploaded
907
+ ? 'flow playback failed'
908
+ : `recording upload failed: ${getLastFlowPreviewUploadError() ?? 'no preview returned'}`,
909
+ ).body.split('\n')[0],
900
910
  }
901
911
  try {
902
912
  for (const name of cloudTestResultFileNames('preview', flowIndex, files.length)) {
903
913
  fs.writeFileSync(path.join(cwd, name), JSON.stringify(previewPayload))
904
914
  }
905
915
  } catch {}
906
- if (!upload?.shareId && !upload?.previewUrl) {
916
+ if (!uploaded) {
907
917
  console.error(' error: required hosted recording upload failed')
908
918
  if (exit === 0) exit = 1
909
919
  if (worstExit === 0) worstExit = 1
@@ -1,5 +1,6 @@
1
1
  import { rnxExit } from '../run-rnx'
2
2
  import { runScreenshotMode } from './screenshot-mode'
3
+ import { runShowTouchesMode } from './show-touches-mode'
3
4
  import { runThreeMode } from './three-mode'
4
5
 
5
6
  interface ModeOptions {
@@ -8,7 +9,7 @@ interface ModeOptions {
8
9
  }
9
10
 
10
11
  const VALUE_FLAGS = new Set(['--sim', '--port', '-p'])
11
- const SUBCOMMANDS = new Set(['screenshot', 'three'])
12
+ const SUBCOMMANDS = new Set(['screenshot', 'three', 'show-touches', 'touches'])
12
13
 
13
14
  function findSubcommand(args: string[]): { name: string; index: number } | null {
14
15
  for (let i = 0; i < args.length; i++) {
@@ -32,21 +33,20 @@ peach mode — toggle live display modes
32
33
 
33
34
  usage:
34
35
  peach mode screenshot [on|off|toggle]
36
+ peach mode show-touches [on|off|toggle]
35
37
  peach mode three [on|off|toggle]
36
38
  peach mode three configure [options]
37
39
  `)
38
40
  }
39
41
 
40
42
  export async function runMode(args: string[], opts: ModeOptions): Promise<void> {
41
- if (args.includes('--help') || args.includes('-h')) {
42
- printHelp()
43
- return
44
- }
45
-
46
43
  const sub = findSubcommand(args)
47
44
  if (!sub) {
48
45
  printHelp()
49
- rnxExit(1)
46
+ if (!args.includes('--help') && !args.includes('-h')) {
47
+ rnxExit(1)
48
+ }
49
+ return
50
50
  }
51
51
 
52
52
  const rest = withoutSubcommand(args, sub.index)
@@ -54,5 +54,9 @@ export async function runMode(args: string[], opts: ModeOptions): Promise<void>
54
54
  await runScreenshotMode(rest, opts)
55
55
  return
56
56
  }
57
+ if (sub.name === 'show-touches' || sub.name === 'touches') {
58
+ await runShowTouchesMode(rest, opts)
59
+ return
60
+ }
57
61
  await runThreeMode(rest, opts)
58
62
  }
@@ -454,6 +454,7 @@ async function runRemoteBox(
454
454
  }
455
455
  const created = await box.createPrebuiltBundleBox({
456
456
  bundle: new Uint8Array(artifact.bytes),
457
+ device: selectedDevice,
457
458
  name: input.name ?? boxNameForBundle(input.bundlePath),
458
459
  endpoint,
459
460
  apiOrigin: box.cloudBoxApiOrigin(args, auth),
@@ -260,7 +260,7 @@ export async function runReportIssue(
260
260
  return 2
261
261
  }
262
262
  const approved = await dependencies.confirmSubmission(
263
- 'send this compatibility report to the peach team??',
263
+ 'send this compatibility report to the peach team?',
264
264
  false,
265
265
  )
266
266
  if (!approved) {
@@ -54,6 +54,7 @@ interface SetupOptions {
54
54
 
55
55
  interface SetupRunOptions {
56
56
  launchConnectRN?: boolean
57
+ port?: number
57
58
  }
58
59
 
59
60
  export interface SetupResult {
@@ -120,6 +121,7 @@ export async function runSetup(
120
121
  channel: 'stable',
121
122
  setActive: true,
122
123
  protectVersions: activeRuntime ? [activeRuntime] : [],
124
+ quiet: !options.json,
123
125
  })
124
126
  const result = options.json
125
127
  ? await install()
@@ -130,10 +132,16 @@ export async function runSetup(
130
132
  let serviceInstalled = serviceStatus.installed
131
133
  if (service && !serviceStatus.installed && !options.dryRun) {
132
134
  if (options.json) {
133
- await daemonInstall({ port: DEFAULT_SOOTSIM_BRIDGE_PORT, force: false })
135
+ await daemonInstall({
136
+ port: runOptions.port ?? DEFAULT_SOOTSIM_BRIDGE_PORT,
137
+ force: false,
138
+ })
134
139
  } else {
135
140
  console.log(`\n ${accent('◆')} ${bold('Enabling the background service')}`)
136
- await daemonInstall({ port: DEFAULT_SOOTSIM_BRIDGE_PORT, force: false })
141
+ await daemonInstall({
142
+ port: runOptions.port ?? DEFAULT_SOOTSIM_BRIDGE_PORT,
143
+ force: false,
144
+ })
137
145
  }
138
146
  serviceInstalled = true
139
147
  }
@@ -144,13 +152,10 @@ export async function runSetup(
144
152
 
145
153
  if (launchConnectRN) {
146
154
  console.log(`\n ${accent('◆')} Opening ConnectRN`)
147
- await runOpenCommand([
148
- 'ConnectRN',
149
- '--driver',
150
- simulatorDriver,
151
- '--no-describe',
152
- '--quiet',
153
- ])
155
+ await runOpenCommand(
156
+ ['ConnectRN', '--driver', simulatorDriver, '--no-describe', '--quiet'],
157
+ { port: runOptions.port },
158
+ )
154
159
  }
155
160
 
156
161
  const productAnalytics = await decidePrivacy(options, privacy)
@@ -324,7 +329,7 @@ async function decideSimulatorDriver(
324
329
  desktopDescription,
325
330
  'The Desktop app opens in a dedicated native window.',
326
331
  ])
327
- const choice = await select('Where should peach open??', [
332
+ const choice = await select('Where should peach open?', [
328
333
  'Browser (recommended) No additional download',
329
334
  desktopChoice,
330
335
  ])
@@ -343,7 +348,7 @@ async function decidePrivacy(
343
348
  'Reports can contain package compatibility or an error class.',
344
349
  'They never contain your code or app data.',
345
350
  ])
346
- return confirm('Share bounded diagnostics to improve peach??', false)
351
+ return confirm('Share bounded diagnostics to improve peach?', false)
347
352
  }
348
353
 
349
354
  async function decideRepository(
@@ -0,0 +1,33 @@
1
+ // peach mode show-touches — toggle OS touch indicators (Show Single Touches / Show taps)
2
+ // in the running peach sim.
3
+
4
+ import { rnxExit } from '../run-rnx'
5
+ import { runShellBooleanMode, type ShellBooleanModeOptions } from './shell-boolean-mode'
6
+
7
+ export async function runShowTouchesMode(args: string[], opts: ShellBooleanModeOptions) {
8
+ if (args.includes('--help') || args.includes('-h')) {
9
+ console.log(`
10
+ peach mode show-touches — toggle touch indicator overlays
11
+
12
+ usage:
13
+ peach mode show-touches [on|off|toggle]
14
+
15
+ arguments:
16
+ on enable touch indicators
17
+ off disable touch indicators
18
+ toggle flip the current state (default)
19
+
20
+ examples:
21
+ peach mode show-touches
22
+ peach mode show-touches on
23
+ peach mode show-touches off
24
+ `)
25
+ rnxExit(0)
26
+ }
27
+
28
+ await runShellBooleanMode(args, opts, {
29
+ modeKey: 'showTouches',
30
+ displayName: 'show-touches',
31
+ actionId: 'toggle-touches',
32
+ })
33
+ }
@@ -9,10 +9,26 @@ import * as fs from 'fs'
9
9
  import * as os from 'os'
10
10
  import * as path from 'path'
11
11
  import { fileURLToPath } from 'url'
12
+ import { getCliVersion } from '../../src/cli-version'
13
+ import { rnxHomeDir } from '../../src/home-paths'
12
14
  import { rnxPublicBrand } from '../../src/public-brand'
13
15
  import { rnxExit } from '../run-rnx'
14
16
 
17
+ declare const __PEACH_BUNDLED_SKILLS__: Record<string, string>
18
+
15
19
  function getSkillsDir(): string {
20
+ // the standalone binary carries its skills instead of a package directory;
21
+ // write them once per cli version so installs can link to real files.
22
+ if (typeof __PEACH_BUNDLED_SKILLS__ !== 'undefined') {
23
+ const dir = path.join(rnxHomeDir(), 'skills', getCliVersion())
24
+ for (const [file, body] of Object.entries(__PEACH_BUNDLED_SKILLS__)) {
25
+ const target = path.join(dir, file)
26
+ if (fs.existsSync(target)) continue
27
+ fs.mkdirSync(path.dirname(target), { recursive: true })
28
+ fs.writeFileSync(target, body)
29
+ }
30
+ return dir
31
+ }
16
32
  try {
17
33
  const pkgPath = fileURLToPath(
18
34
  import.meta.resolve(`${rnxPublicBrand.packageName}/package.json`),
@@ -873,6 +873,20 @@ export async function runCloudTest(
873
873
 
874
874
  const { id } = (await response.json()) as { id: string }
875
875
  console.log(` cloud test job submitted: ${id}`)
876
+ // a job whose app this process serves over its tunnel cannot finish once
877
+ // the process exits, so an interrupt cancels it rather than leaving it to
878
+ // fail flow by flow while it bills.
879
+ if (closeTunnel) {
880
+ const cancelOnSignal = () => {
881
+ console.error(`\n interrupted: cancelling ${id}`)
882
+ void cancelCloudTest(id, origin).finally(() => {
883
+ closeTunnel?.()
884
+ process.exit(130)
885
+ })
886
+ }
887
+ process.once('SIGINT', cancelOnSignal)
888
+ process.once('SIGTERM', cancelOnSignal)
889
+ }
876
890
 
877
891
  const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
878
892
  const startedAt = Date.now()
@@ -970,7 +984,14 @@ export async function runTest(
970
984
  return runCloudTest(args, opts)
971
985
  }
972
986
 
973
- const selected = resolveTestCommand(args)
987
+ // a missing or unknown target is the caller's mistake: one line, no stack.
988
+ let selected: ReturnType<typeof resolveTestCommand>
989
+ try {
990
+ selected = resolveTestCommand(args)
991
+ } catch (error) {
992
+ console.error(` error: ${error instanceof Error ? error.message : String(error)}`)
993
+ return 1
994
+ }
974
995
  const app = appFromArgs(args)
975
996
  const appTarget = app
976
997
  ? await resolveTestAppTarget(app)
@@ -194,7 +194,8 @@ options:
194
194
  --repo <repo> repo name for --owner; the signed-in user must belong
195
195
  to the local/org team that owns the link
196
196
  --billing-kind test_run
197
- meter this upload as a natural-language test run
197
+ the recording of a hosted test job, covered by the
198
+ job's minutes (set by the hosted recorder)
198
199
  --sim <sim> target a specific sim (see: peach list)
199
200
  --open open the resulting /preview/<id> url in the browser
200
201
  --assets-only drop API/JSON/HTML records before upload; keep images,
@@ -476,6 +477,12 @@ export async function runUpload(
476
477
  console.error(` invalid --billing-kind: ${billingKind}`)
477
478
  rnxExit(1)
478
479
  }
480
+ // the hosted recorder names the job whose minutes cover this recording
481
+ const runnerJobId = process.env.CONTRAST_RUNNER_JOB_ID
482
+ if (billingKind === 'test_run' && !runnerJobId) {
483
+ console.error(' --billing-kind test_run is only for hosted test jobs')
484
+ rnxExit(1)
485
+ }
479
486
  if (!eventsPath && !videoPath) {
480
487
  console.error(
481
488
  ' preview uploads require playback data: pass --events or --video.\n' +
@@ -1045,6 +1052,7 @@ export async function runUpload(
1045
1052
  pullRequestNumber: provenance.pullRequestNumber ?? undefined,
1046
1053
  pullRequestTitle: provenance.pullRequestTitle ?? undefined,
1047
1054
  billingKind: billingKind === 'test_run' ? 'test_run' : undefined,
1055
+ runnerJobId: billingKind === 'test_run' ? runnerJobId : undefined,
1048
1056
  runScope: runScope || undefined,
1049
1057
  files: prepared.map((f) => ({
1050
1058
  url: f.url,
@@ -1161,19 +1169,21 @@ export async function runUpload(
1161
1169
  if (e.phase === 'init' && e.status === 401) {
1162
1170
  console.error(' preview upload requires a valid login.')
1163
1171
  console.error(' run `peach login` and retry.')
1164
- rnxExit(1)
1172
+ rnxExit(1, 'preview upload requires a valid login')
1165
1173
  }
1166
1174
  if (e.status === 402) {
1167
1175
  // billing gate — surface the server's own `message`, not raw JSON.
1168
- console.error(
1169
- ` ${parseServerMessage(e.responseText) ?? 'preview uploads require Personal, Team, or an active trial — upgrade from the billing dialog.'}`,
1170
- )
1171
- rnxExit(1)
1176
+ const message =
1177
+ parseServerMessage(e.responseText) ??
1178
+ 'preview uploads require Personal, Team, or an active trial — upgrade from the billing dialog.'
1179
+ console.error(` ${message}`)
1180
+ rnxExit(1, message)
1172
1181
  }
1173
1182
  // PresignedUploadError.message already carries the "<phase> failed:"
1174
1183
  // prefix — don't re-prefix it (that produced "init failed: init failed:").
1175
- console.error(` ${e.message ?? `${e.phase ?? 'upload'} failed: ${String(err)}`}`)
1176
- rnxExit(1)
1184
+ const message = e.message ?? `${e.phase ?? 'upload'} failed: ${String(err)}`
1185
+ console.error(` ${message}`)
1186
+ rnxExit(1, message)
1177
1187
  }
1178
1188
 
1179
1189
  const previewUrl = `${publicOrigin}${finalize.url}`
@@ -4,14 +4,14 @@ import { printBrand } from '../ui'
4
4
  import { runOpenCommand } from './control'
5
5
  import { runSetup } from './setup'
6
6
 
7
- export async function runWelcome(): Promise<number> {
7
+ export async function runWelcome(port: number | undefined): Promise<number> {
8
8
  if (!isOnboardingComplete() && canPromptForPrivacyChoice()) {
9
- await runSetup([], { launchConnectRN: true })
9
+ await runSetup([], { launchConnectRN: true, port })
10
10
  return 0
11
11
  }
12
12
 
13
13
  printBrand('Run any React Native app in a local simulator.')
14
14
  console.log(`\n Opening ConnectRN`)
15
- await runOpenCommand(['ConnectRN', '--no-describe', '--quiet'])
15
+ await runOpenCommand(['ConnectRN', '--no-describe', '--quiet'], { port })
16
16
  return 0
17
17
  }
@@ -7,8 +7,10 @@ import { createBrowserPointerDispatcher } from '../../../peach-engine/src/render
7
7
  //
8
8
  // it resolves playwright from the absolute path the driver hands it, launches
9
9
  // Chrome for Testing, opens the target url, then keeps itself (and therefore
10
- // the browser) alive until the browser disconnects. all inputs arrive through
11
- // the environment.
10
+ // the browser) alive until the page asks it to close (`peach close`, or the
11
+ // bridge reaping the sim after AUTOMATION_SIM_IDLE_REAP_TTL_MS with no cli),
12
+ // the browser disconnects, or its owning session exits. all inputs arrive
13
+ // through the environment.
12
14
  //
13
15
  // it stays a source string rather than a module of its own: playwright is
14
16
  // resolved from the user's project at runtime and is not a dependency here, so
@@ -444,16 +446,17 @@ const cleanupProfile = () => {
444
446
  const request = readBrowserCompositeCaptureRequest(value);
445
447
  const session = await source.page.context().newCDPSession(source.page);
446
448
  try {
449
+ const { clip, originX, originY } = resolveBrowserCompositeCdpClip(
450
+ request,
451
+ await session.send('Page.getLayoutMetrics'),
452
+ );
447
453
  const result = await session.send('Page.captureScreenshot', {
448
454
  format: 'png',
449
455
  fromSurface: true,
450
456
  captureBeyondViewport: true,
451
- clip: resolveBrowserCompositeCdpClip(
452
- request,
453
- await session.send('Page.getLayoutMetrics'),
454
- ),
457
+ clip,
455
458
  });
456
- return 'data:image/png;base64,' + result.data;
459
+ return { dataUrl: 'data:image/png;base64,' + result.data, originX, originY };
457
460
  } finally {
458
461
  await session.detach();
459
462
  }
@@ -515,7 +518,12 @@ const cleanupProfile = () => {
515
518
  process.stderr.write('[console.error] ' + m.text() + '\\n');
516
519
  }
517
520
  });
518
- page.on('crash', () => process.stderr.write('[page] renderer crashed\\n'));
521
+ // a crashed renderer never runs the page's bridge close path, so nothing
522
+ // would ever close this host.
523
+ page.on('crash', () => {
524
+ process.stderr.write('[page] renderer crashed; closing playwright host\\n');
525
+ void shutdown(1);
526
+ });
519
527
  try {
520
528
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
521
529
  } catch (err) {
@@ -16,6 +16,7 @@ import { spawn } from 'child_process'
16
16
  import { closeSync, mkdtempSync, openSync, readFileSync } from 'fs'
17
17
  import { tmpdir } from 'os'
18
18
  import { join } from 'path'
19
+ import { AUTOMATION_SIM_IDLE_REAP_TTL_MS } from '../../src/bridge-constants'
19
20
  import { playwrightHostLogName } from '../../src/playwright-host-log'
20
21
  import { ensureProfile, playwrightProfileUserDataDir } from '../../src/profiles'
21
22
  import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from '../internal-child'
@@ -172,12 +173,14 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
172
173
  }${hint ? `\n${hint}` : ''}`,
173
174
  }
174
175
  }
175
- // name the lifetime tie: the host watchdog closes chrome ~5s after the
176
- // owning session pid exits, which surprises one-shot/detached shells
177
- // (their topmost non-init ancestor is the shell itself, so the sim dies
178
- // the moment the command finishes).
176
+ // name both lifetime bounds: the bridge closes an automation sim no cli
177
+ // has touched for the idle window, and the host watchdog closes chrome ~5s
178
+ // after the owning session pid exits, which surprises one-shot/detached
179
+ // shells (their topmost non-init ancestor is the shell itself, so the sim
180
+ // dies the moment the command finishes).
179
181
  const ownerComm = getProcessComm(ownerPid)
180
- const ownerNote = ` — browser closes when pid ${ownerPid}${ownerComm ? ` (${ownerComm})` : ''} exits`
182
+ const idleMinutes = AUTOMATION_SIM_IDLE_REAP_TTL_MS / 60_000
183
+ const ownerNote = ` — browser closes after ${idleMinutes} min with no peach command, or when pid ${ownerPid}${ownerComm ? ` (${ownerComm})` : ''} exits`
181
184
  return {
182
185
  launched: true,
183
186
  message: `playwright chrome launched → ${opts.url}${ownerNote}`,
package/cli/help-core.ts CHANGED
@@ -96,8 +96,13 @@ Run \`peach <command> --help\` for leaf availability and usage.`
96
96
  export function renderRnxHelp(options: RnxHelpOptions): string {
97
97
  const command = options.command ?? null
98
98
  const commandArgs = options.commandArgs ?? []
99
+ // the preview selectors and backend lists describe the factory's browser
100
+ // backend; the public cli runs the node backend and never sees them.
101
+ const factory = options.backend === 'browser'
99
102
  if (!command) {
100
- return `${renderCliIndexHelp(HELP_CONTEXT, formatFlagHelp()).trimEnd()}
103
+ const index = renderCliIndexHelp(HELP_CONTEXT, formatFlagHelp()).trimEnd()
104
+ if (!factory) return index
105
+ return `${index}
101
106
 
102
107
  browser preview selectors:
103
108
  peach web select the existing Factory web preview
@@ -108,7 +113,7 @@ ${renderRootAvailability(options.backend)}`
108
113
  }
109
114
 
110
115
  let body: string | null
111
- if (command === 'web' || command === 'ios' || command === 'android') {
116
+ if (factory && (command === 'web' || command === 'ios' || command === 'android')) {
112
117
  body = renderPreviewHelp(command)
113
118
  } else if (options.group === command && !options.prefer) {
114
119
  body = renderCliGroupHelp(command, HELP_CONTEXT, formatFlagHelp())
@@ -130,11 +135,11 @@ ${renderRootAvailability(options.backend)}`
130
135
  const registration = getRnxCommandRegistration(command)
131
136
  if (!registration) return body
132
137
  const current = getRnxCommandAvailability(command, commandArgs, options.backend)
138
+ if (!factory && current.available) return body
133
139
  const currentLine = current.available
134
140
  ? `${options.backend} backend: available`
135
141
  : `${options.backend} backend: ${current.reason}`
136
- const factoryLeaves =
137
- options.backend === 'browser' ? renderFactoryBrowserLeaves(command) : null
142
+ const factoryLeaves = factory ? renderFactoryBrowserLeaves(command) : null
138
143
  return `${body.trimEnd()}
139
144
 
140
145
  ${factoryLeaves ? `${factoryLeaves}\n\n` : ''}${backendLabel(command, commandArgs)}