peachsim 0.1.629 → 0.1.631

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 (128) hide show
  1. package/README.md +8 -4
  2. package/cli/app-fonts.ts +23 -4
  3. package/cli/bridge-flow-runner.ts +45 -31
  4. package/cli/cli-update.ts +2 -7
  5. package/cli/cloud-test-tunnel.ts +16 -3
  6. package/cli/command-registry.ts +1 -0
  7. package/cli/commands/compat.ts +11 -60
  8. package/cli/commands/config.ts +1 -1
  9. package/cli/commands/control.ts +40 -8
  10. package/cli/commands/daemon-mac-app.ts +4 -2
  11. package/cli/commands/daemon.ts +4 -4
  12. package/cli/commands/detect.ts +289 -26
  13. package/cli/commands/device.ts +2 -3
  14. package/cli/commands/inspect/actions.ts +125 -90
  15. package/cli/commands/inspect/core.ts +1 -1
  16. package/cli/commands/inspect.ts +10 -1
  17. package/cli/commands/maestro.ts +3 -1
  18. package/cli/commands/platform.ts +1 -1
  19. package/cli/commands/project-report.ts +386 -0
  20. package/cli/commands/projectReportContract.ts +42 -0
  21. package/cli/commands/report-issue.ts +38 -54
  22. package/cli/commands/screenshot-layers.ts +1 -1
  23. package/cli/commands/screenshot.ts +1 -1
  24. package/cli/commands/setup.ts +6 -4
  25. package/cli/commands/skills.ts +0 -31
  26. package/cli/commands/telemetry.ts +8 -3
  27. package/cli/commands/test.ts +347 -134
  28. package/cli/drivers/playwright-provisioning.ts +3 -2
  29. package/cli/drivers/playwright-sim-host.ts +25 -9
  30. package/cli/main.ts +19 -3
  31. package/cli/outbound-endpoints.ts +2 -5
  32. package/cli/privacy.ts +1 -2
  33. package/cli/runtime-diagnostics.ts +51 -0
  34. package/cli/telemetry.ts +78 -7
  35. package/detox/index.ts +77 -51
  36. package/dist-lib/agent-daemon-client.cjs +1 -1
  37. package/dist-lib/agent-events.cjs +1 -1
  38. package/dist-lib/agent-identity.cjs +1 -1
  39. package/dist-lib/agent-sessions.cjs +1 -1
  40. package/dist-lib/attached-projects.cjs +1 -1
  41. package/dist-lib/auth/shared-session.cjs +1 -1
  42. package/dist-lib/backend-origin.cjs +1 -1
  43. package/dist-lib/beta.cjs +1 -1
  44. package/dist-lib/beta.mjs +1 -1
  45. package/dist-lib/bridge-constants.cjs +1 -1
  46. package/dist-lib/bridge-contract-input.cjs +1 -1
  47. package/dist-lib/bridge-contract-input.mjs +1 -1
  48. package/dist-lib/bridge-contract.cjs +1 -1
  49. package/dist-lib/bridge-contract.mjs +1 -1
  50. package/dist-lib/capture-contract.cjs +1 -1
  51. package/dist-lib/capture-contract.mjs +1 -1
  52. package/dist-lib/cli-constants.cjs +1 -1
  53. package/dist-lib/cloud-contract.cjs +1 -1
  54. package/dist-lib/cloud-contract.mjs +1 -1
  55. package/dist-lib/cloud-test-args.cjs +1 -1
  56. package/dist-lib/cloud-test-args.mjs +1 -1
  57. package/dist-lib/cloud.cjs +1 -1
  58. package/dist-lib/cloud.mjs +1 -1
  59. package/dist-lib/config.cjs +1 -1
  60. package/dist-lib/detox/index.cjs +130 -33
  61. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  62. package/dist-lib/home-paths.cjs +8 -5
  63. package/dist-lib/host/bridge-host.cjs +353 -290
  64. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  65. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  66. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  67. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  68. package/dist-lib/host/websocket-proxy.cjs +1 -1
  69. package/dist-lib/index.cjs +257 -121
  70. package/dist-lib/jump-to-source-babel.cjs +1 -1
  71. package/dist-lib/jump-to-source-native.cjs +1 -1
  72. package/dist-lib/{vite-kotlin.cjs → kotlin-host.cjs} +130 -19
  73. package/dist-lib/menu.cjs +11 -1
  74. package/dist-lib/menu.mjs +11 -1
  75. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  76. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  77. package/dist-lib/metro-production-bundle.cjs +1 -1
  78. package/dist-lib/metro-production-bundle.mjs +1 -1
  79. package/dist-lib/metro.cjs +132 -9
  80. package/dist-lib/native-app-identity.cjs +278 -0
  81. package/dist-lib/native-app-identity.mjs +245 -0
  82. package/dist-lib/profiles.cjs +1 -1
  83. package/dist-lib/public-brand.cjs +1 -1
  84. package/dist-lib/react-native-host-modules.cjs +1 -1
  85. package/dist-lib/react-native-host-modules.mjs +1 -1
  86. package/dist-lib/render-mode.cjs +1 -1
  87. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  88. package/dist-lib/sdk.cjs +112 -87
  89. package/dist-lib/sdk.mjs +111 -87
  90. package/dist-lib/skills.cjs +1270 -709
  91. package/dist-lib/swift-compile.cjs +1 -1
  92. package/dist-lib/swift-compile.mjs +1 -1
  93. package/dist-lib/swift-package.cjs +1 -1
  94. package/dist-lib/swift-package.mjs +1 -1
  95. package/dist-lib/swift-submit.cjs +1 -1
  96. package/dist-lib/swift-submit.mjs +1 -1
  97. package/dist-lib/swift.cjs +672 -275
  98. package/dist-lib/vite-swift.cjs +1 -1
  99. package/dist-lib/vite.cjs +11 -28
  100. package/kotlin/README.md +32 -4
  101. package/kotlin/fixtures/counter/Counter.kt +26 -0
  102. package/kotlin/fixtures/counter/CounterScreen.kt +40 -0
  103. package/kotlin/fixtures/headless-counter/Headless.kt +228 -0
  104. package/kotlin/fixtures/runtime-state/RuntimeState.kt +200 -0
  105. package/kotlin/fork/LICENSE +178 -0
  106. package/kotlin/fork/NOTICE +25 -0
  107. package/kotlin/fork/README.md +59 -0
  108. package/kotlin/fork/build.sh +128 -0
  109. package/kotlin/fork/compose-worker-globals.patch +96 -0
  110. package/kotlin/fork/fork.json +33 -0
  111. package/kotlin/fork/publish.ts +77 -0
  112. package/kotlin/fork/skiko-worker-clock.patch +33 -0
  113. package/kotlin/toolchain.json +288 -0
  114. package/package.json +13 -5
  115. package/skills/contrast/SKILL.md +1 -0
  116. package/src/automatic-update-policy.ts +17 -0
  117. package/src/home-paths.ts +11 -9
  118. package/src/host/bridge-host.ts +3 -1
  119. package/src/{vite-plugin-kotlin.ts → kotlin-host.ts} +155 -12
  120. package/src/menu.ts +11 -0
  121. package/src/metro-plugin.ts +18 -0
  122. package/src/native-app-identity.ts +376 -0
  123. package/src/native-firebase-config.ts +90 -0
  124. package/src/screenshots/frame-compose.ts +1 -1
  125. package/src/screenshots/registry.ts +1 -1
  126. package/src/screenshots/schema.ts +2 -2
  127. package/src/vite-plugin-one.ts +11 -28
  128. package/src/vite-plugin.ts +6 -1
@@ -2,9 +2,9 @@ import { spawnSync } from 'node:child_process'
2
2
  import { existsSync, readFileSync } from 'node:fs'
3
3
  import { createRequire } from 'node:module'
4
4
  import { dirname, join } from 'node:path'
5
+ import { isRecord } from '../../src/is-record'
5
6
  import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from '../internal-child'
6
7
  import { rnxSelfInvocation } from '../self-invocation'
7
- import { isRecord } from '../../src/is-record'
8
8
 
9
9
  export interface ResolvedPlaywright {
10
10
  // package name that resolved (for human-facing messaging)
@@ -32,7 +32,8 @@ export function resolvePlaywright(): ResolvedPlaywright | null {
32
32
  // resolve from several roots, not just cwd. a user can invoke a global CLI
33
33
  // from an arbitrary directory while Playwright is installed beside the CLI.
34
34
  const roots = [`${process.cwd()}/`]
35
- if (process.argv[1]) roots.push(process.argv[1])
35
+ const self = rnxSelfInvocation()
36
+ roots.push(self.prefixArgs[0] ?? self.executable)
36
37
  for (const root of roots) {
37
38
  try {
38
39
  const requireFromRoot = createRequire(root)
@@ -2,6 +2,11 @@ import {
2
2
  readBrowserCompositeCaptureRequest,
3
3
  resolveBrowserCompositeCdpClip,
4
4
  } from '../../../peach-engine/src/capture/browser-composite'
5
+ import {
6
+ AUTH_BROWSER_EXTERNAL_NAVIGATION_EVENT,
7
+ AUTH_BROWSER_SESSION_FRAGMENT_KEY,
8
+ forwardPopupSchemeNavigations,
9
+ } from '../../../peach-engine/src/open-external'
5
10
  import { createBrowserPointerDispatcher } from '../../../peach-engine/src/render-worker/browser-pointer'
6
11
  // the detached browser host, as CJS source.
7
12
  //
@@ -46,11 +51,13 @@ const cdpPort = process.env.SOOTSIM_PW_CDP_PORT || '';
46
51
  const extraChromeArgs = (process.env.SOOTSIM_PW_CHROME_ARGS || '')
47
52
  .split(' ')
48
53
  .filter(Boolean);
49
- // Local CanvasKit work must never fall back to Playwright's software
50
- // renderer. CI may use the bundled browser for non-interactive coverage, but
51
- // local test drives fail closed unless the hardware-backed test browser is
52
- // proven after launch.
53
- const hardwareGpuRequired = process.env.CI !== '1' && process.env.CI !== 'true';
54
+ // A Mac always has a GPU, so a local macOS drive that lands on Playwright's
55
+ // software renderer is a misconfiguration that pegs every core: it fails
56
+ // closed unless the hardware-backed test browser is proven after launch. CI
57
+ // and Linux or Windows hosts, which are often GPU-less servers, use the
58
+ // bundled browser and render in software when no GPU is present.
59
+ const hardwareGpuRequired =
60
+ process.platform === 'darwin' && process.env.CI !== '1' && process.env.CI !== 'true';
54
61
  const softwareRendererPattern = /swiftshader|software(?: rasterizer)?|llvmpipe|lavapipe|softpipe/i;
55
62
  // page viewport as '<w>x<h>'. the caller sizes it from the device profile;
56
63
  // the fallback is phone-shaped because the page hosts a phone sim — the
@@ -74,6 +81,7 @@ const { execSync } = require('child_process');
74
81
  const { tmpdir } = require('os');
75
82
  const { join } = require('path');
76
83
  const createBrowserPointerDispatcher = ${createBrowserPointerDispatcher.toString()};
84
+ const forwardPopupSchemeNavigations = ${forwardPopupSchemeNavigations.toString()};
77
85
  const readBrowserCompositeCaptureRequest = ${readBrowserCompositeCaptureRequest.toString()};
78
86
  const resolveBrowserCompositeCdpClip = ${resolveBrowserCompositeCdpClip.toString()};
79
87
  // chrome tree reaper — playwright's browser.close() and even SIGKILLing the
@@ -300,9 +308,9 @@ const cleanupProfile = () => {
300
308
  startOwnerWatch();
301
309
  // Local rendering uses Playwright's full Chrome for Testing app. On macOS its
302
310
  // com.google.chrome.for.testing identity cannot intercept open/quit events for
303
- // the user's com.google.Chrome browser. CI may use chromium_headless_shell;
304
- // local rendering fails closed because that shell has no GPU and silently
305
- // chooses SwiftShader. NOTE: this is NOT enough to make \`peach perf cpu\`
311
+ // the user's com.google.Chrome browser. CI and non-Mac hosts use
312
+ // chromium_headless_shell; local Mac rendering fails closed because that
313
+ // shell has no GPU and silently chooses SwiftShader. NOTE: this is NOT enough to make \`peach perf cpu\`
306
314
  // work — that profiles the tenant *worker*, and the JS Self-Profiler API
307
315
  // cannot be constructed in a dedicated worker (see
308
316
  // worker-handlers/cpu-profile.ts). cpu-profile needs a CDP-based profiler
@@ -317,7 +325,8 @@ const cleanupProfile = () => {
317
325
  throw new Error('[playwright-driver] refused SwiftShader launch arguments');
318
326
  }
319
327
  // one browser path per environment: the full, separately identified test app
320
- // locally; chromium_headless_shell on CI. never launch a branded user browser.
328
+ // on a local Mac; the bundled chromium everywhere else. never launch a
329
+ // branded user browser.
321
330
  const channelsToTry = hardwareGpuRequired ? ['chromium'] : [null];
322
331
  const launchWith = async (channel) => {
323
332
  const opts = channel
@@ -429,6 +438,13 @@ const cleanupProfile = () => {
429
438
  await context.addInitScript({
430
439
  content: 'Object.defineProperty(window, "__sootsimHostMeta", { value: ' + JSON.stringify(hostMeta) + ', configurable: true });' + bridgePortInject,
431
440
  });
441
+ // ASWebAuthenticationSession completes on a navigation to its callback
442
+ // scheme, which a browser popup cannot follow.
443
+ await forwardPopupSchemeNavigations(
444
+ context,
445
+ ${JSON.stringify(AUTH_BROWSER_EXTERNAL_NAVIGATION_EVENT)},
446
+ ${JSON.stringify(AUTH_BROWSER_SESSION_FRAGMENT_KEY)},
447
+ );
432
448
  await context.exposeBinding('__sootsimHostClose', async () => {
433
449
  await shutdown(0);
434
450
  });
package/cli/main.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // single lazy-imported command a run dispatches to. `./bin` imports this only
3
3
  // when none of its hidden entry points claimed the process.
4
4
 
5
+ import { automaticPeachUpdatesEnabled } from '../src/automatic-update-policy'
5
6
  import { rnxPublicBrand } from '../src/public-brand'
6
7
  import { levenshtein } from './levenshtein'
7
8
  import { COMMANDS, parseArgs, TOP_LEVEL_RUNTIME_COMMANDS } from './parse-args'
@@ -21,7 +22,10 @@ import { IS_STANDALONE } from './standalone'
21
22
  // "command timed out after 10s" message and is uncopyable for a bug report.
22
23
  // rejections that don't match this shape still get the verbose stack so we
23
24
  // don't paper over real bugs.
24
- function handleTopLevelError(err: unknown): never {
25
+ async function handleTopLevelError(err: unknown): Promise<never> {
26
+ const { trackCliError, flushCliTelemetry } = await import('./telemetry')
27
+ trackCliError(err)
28
+ await flushCliTelemetry()
25
29
  const msg = err instanceof Error ? err.message : String(err)
26
30
  // peach's known top-level errors are all single-line, narrow strings.
27
31
  // everything else gets the full stack so unexpected crashes still surface.
@@ -92,7 +96,7 @@ const isReadOnlyInvocation =
92
96
  parsed.commandArgs.includes('--dry-run')
93
97
 
94
98
  if (isCliUpdateWorker) {
95
- if (IS_STANDALONE) {
99
+ if (IS_STANDALONE && automaticPeachUpdatesEnabled()) {
96
100
  try {
97
101
  const { refreshCliUpdateCache } = await import('./cli-update')
98
102
  await refreshCliUpdateCache()
@@ -119,7 +123,12 @@ if (
119
123
  // interactive command runs next prints it once (stderr, so --json stdout
120
124
  // stays parseable) and deletes it. the daemon/serve processes skip it so a
121
125
  // background process can't eat the notice before a human sees it.
122
- if (!isReadOnlyInvocation && parsed.command !== 'serve' && parsed.command !== 'daemon') {
126
+ if (
127
+ automaticPeachUpdatesEnabled() &&
128
+ !isReadOnlyInvocation &&
129
+ parsed.command !== 'serve' &&
130
+ parsed.command !== 'daemon'
131
+ ) {
123
132
  const { consumeRuntimeUpgradeNotice } = await import('../src/home-paths')
124
133
  const notice = consumeRuntimeUpgradeNotice()
125
134
  if (notice) {
@@ -589,6 +598,11 @@ try {
589
598
  break
590
599
  }
591
600
 
601
+ case 'project-report': {
602
+ const { runProjectReport } = await import('./commands/project-report')
603
+ await exitWithFlush(runProjectReport(parsed.commandArgs))
604
+ }
605
+
592
606
  case 'report-issue': {
593
607
  const { runReportIssue } = await import('./commands/report-issue')
594
608
  const code = await runReportIssue(parsed.commandArgs)
@@ -708,5 +722,7 @@ try {
708
722
  throw error
709
723
  }
710
724
 
725
+ const { flushCliTelemetry } = await import('./telemetry')
726
+ await flushCliTelemetry()
711
727
  await scheduleAutomaticCliUpdate(0)
712
728
  await scheduleAutomaticCleanup()
@@ -38,7 +38,8 @@ export const OUTBOUND_ENDPOINTS: Record<string, OutboundEndpointDeclaration> = {
38
38
  category: 'bounded-diagnostics',
39
39
  destination: 'configured PostHog ingestion host',
40
40
  preConsent: false,
41
- payload: 'package name/version/unsupported API or error class only',
41
+ payload:
42
+ 'anonymous install id, CLI/runtime versions, OS/arch, command name, package compatibility fields, error class and bounded path-redacted first-line error/warning messages',
42
43
  },
43
44
  contrast_user_action: {
44
45
  category: 'explicit-user-action',
@@ -349,12 +350,8 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
349
350
  category: 'guest_app_network',
350
351
  count: 1,
351
352
  },
352
- 'packages/peach-engine/src/engine/config-plugin-splash-loader.ts :: fetch :: manifestFetchUrl(origin)':
353
- { category: 'local_development', count: 1 },
354
353
  'packages/peach-engine/src/engine/emoji-metrics-font-loader.ts :: fetch :: attemptedBundledUrl':
355
354
  { category: 'same_origin_runtime', count: 1 },
356
- 'packages/peach-engine/src/engine/expo-app-config.ts :: fetch :: manifestFetchUrl(origin)':
357
- { category: 'local_development', count: 1 },
358
355
  'packages/peach-engine/src/engine/image-loader.ts :: fetch :: resolveImageFetchUri(uri)':
359
356
  { category: 'guest_app_network', count: 1 },
360
357
  'packages/peach-engine/src/engine/language-font-loader.ts :: fetch :: url': {
package/cli/privacy.ts CHANGED
@@ -23,7 +23,6 @@ export function diagnosticsAllowedForCurrentProcess(
23
23
  ): boolean {
24
24
  const doNotTrack = process.env.DO_NOT_TRACK
25
25
  if (doNotTrack === '1' || doNotTrack === 'true') return false
26
- if (!canPromptForPrivacyChoice()) return false
27
26
  return preferences.configured && preferences.productAnalytics
28
27
  }
29
28
 
@@ -42,7 +41,7 @@ export async function ensurePrivacyChoice(options: {
42
41
  }
43
42
 
44
43
  const allowed = await confirm(
45
- 'Share anonymous package incompatibilities and error classes to improve peach?',
44
+ 'Share anonymous diagnostics, including CI and agent runs: CLI/runtime versions, OS/arch, command name, random install ID, package incompatibilities, error class, and first-line error/warning messages (300 characters, paths redacted; no arguments, stacks, source or structured app data)?',
46
45
  false,
47
46
  )
48
47
  // the engine's historical analytics and crash transports carry broader
@@ -0,0 +1,51 @@
1
+ import { consoleEntriesEval, type InspectBridge } from './commands/inspect/core'
2
+ import { diagnosticsAllowedForCurrentProcess } from './privacy'
3
+ import { setDiagnosticRuntimeVersion, trackCliDiagnostic } from './telemetry'
4
+
5
+ // reuse the same local console/error stores the CLI already inspects. only
6
+ // message strings cross into the diagnostic queue, never stacks or objects.
7
+ export async function collectRuntimeDiagnostics(bridge: InspectBridge): Promise<void> {
8
+ if (!diagnosticsAllowedForCurrentProcess()) return
9
+ try {
10
+ const result: unknown = await bridge.send(
11
+ {
12
+ type: 'evaluate',
13
+ code: `(() => {
14
+ const messages = []
15
+ const add = (kind, message) => {
16
+ if (typeof message === 'string' && message) messages.push({ kind, message: message.slice(0, 2048) })
17
+ }
18
+ const obs = window.__sootsimObservability
19
+ for (const e of (obs?.reportedErrors?.getSnapshot?.() || []).slice(-20)) add('runtime_error', e.message)
20
+ for (const [kind, entries] of [
21
+ ['runtime_error', ${consoleEntriesEval('error', 20)}],
22
+ ['runtime_warning', ${consoleEntriesEval('warn', 20)}],
23
+ ]) {
24
+ for (const e of entries) {
25
+ const parts = e.args.map(a => typeof a === 'string' ? a : typeof a?.message === 'string' ? a.message : '').filter(Boolean)
26
+ add(kind, parts.join(' '))
27
+ }
28
+ }
29
+ return { runtimeVersion: window.__sootsimRuntimeVersion, messages: messages.slice(0, 50) }
30
+ })()`,
31
+ },
32
+ { timeoutMs: 1000 },
33
+ )
34
+ if (!result || typeof result !== 'object') return
35
+ if ('runtimeVersion' in result && typeof result.runtimeVersion === 'string') {
36
+ setDiagnosticRuntimeVersion(result.runtimeVersion)
37
+ }
38
+ if (!('messages' in result) || !Array.isArray(result.messages)) return
39
+ for (const entry of result.messages.slice(0, 50)) {
40
+ if (!entry || typeof entry !== 'object') continue
41
+ if (
42
+ (entry.kind === 'runtime_error' || entry.kind === 'runtime_warning') &&
43
+ typeof entry.message === 'string'
44
+ ) {
45
+ trackCliDiagnostic({ kind: entry.kind, message: entry.message })
46
+ }
47
+ }
48
+ } catch {
49
+ // diagnostics never delay a failed bridge beyond this bounded probe.
50
+ }
51
+ }
package/cli/telemetry.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { homedir } from 'node:os'
2
+ import { getCliVersion } from '../src/cli-version'
3
+ import { readActiveRuntime, readOrCreateAnonymousId } from '../src/home-paths'
4
+ import { parseArgs } from './parse-args'
1
5
  import { diagnosticsAllowedForCurrentProcess } from './privacy'
2
6
 
3
7
  type PackageIncompatibility = {
@@ -10,9 +14,15 @@ type PackageIncompatibility = {
10
14
  type ErrorShape = {
11
15
  kind: 'error_shape'
12
16
  errorClass: string
17
+ message: string
13
18
  }
14
19
 
15
- export type CliDiagnostic = PackageIncompatibility | ErrorShape
20
+ type RuntimeMessage = {
21
+ kind: 'runtime_error' | 'runtime_warning'
22
+ message: string
23
+ }
24
+
25
+ export type CliDiagnostic = PackageIncompatibility | ErrorShape | RuntimeMessage
16
26
 
17
27
  type Config = { apiKey: string; host: string }
18
28
 
@@ -32,6 +42,46 @@ function bounded(value: string, max: number): string {
32
42
  return value.trim().slice(0, max)
33
43
  }
34
44
 
45
+ export function sanitizeDiagnosticMessage(value: string): string {
46
+ const firstLine = value.slice(0, 2048).split(/[\r\n\u2028\u2029]/, 1)[0] ?? ''
47
+ // retain package names from dependency paths, never source filenames,
48
+ // URL paths/queries, local paths, stack frames, or serialized app objects.
49
+ return firstLine
50
+ .split(homedir())
51
+ .join('<home>')
52
+ .replace(
53
+ /<package:(?:@[\w.-]+\/)?[\w.-]+>|(?:https?:\/\/|file:\/\/|[A-Za-z]:[\\/]|<home>[\\/]|\/|\.{1,2}[\\/]|\b[\w.-]+[\\/])[^\s"'<>]+/g,
54
+ (location) => {
55
+ if (location.startsWith('<package:')) return location
56
+ const dependency = /(?:^|[\\/])node_modules[\\/]((?:@[^\\/]+[\\/])?[^\\/]+)/.exec(
57
+ location,
58
+ )
59
+ return dependency ? `<package:${dependency[1]?.replace(/\\/g, '/')}>` : '<path>'
60
+ },
61
+ )
62
+ .replace(/\b[\w.-]+\.(?:tsx?|jsx?|swift|m|mm|kt|java)(?::\d+(?::\d+)?)?/g, '<source>')
63
+ .replace(/\{.*$|\[(?:\s*["'{\d]|$).*$/g, '<data>')
64
+ .replace(
65
+ /\b(?:const|let|var)\s+\w+\s*=.*$|\b(?:function|class)\s+\w+.*$|\b(?:import|export)\s+.*$|\breturn\s*[({<].*$/g,
66
+ '<source>',
67
+ )
68
+ .replace(/^\s*at\s+.*$|\bat\s+[\w.$<>]+\s*\([^)]*\)/g, '<stack>')
69
+ .trim()
70
+ .slice(0, 300)
71
+ }
72
+
73
+ let observedRuntimeVersion: string | null = null
74
+
75
+ function diagnosticVersion(version: string | null): string | null {
76
+ return version && version.length <= 80 && /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version)
77
+ ? version
78
+ : null
79
+ }
80
+
81
+ export function setDiagnosticRuntimeVersion(version: string): void {
82
+ observedRuntimeVersion = diagnosticVersion(version)
83
+ }
84
+
35
85
  export function buildCliDiagnosticEvent(diagnostic: CliDiagnostic) {
36
86
  const properties =
37
87
  diagnostic.kind === 'package_incompatibility'
@@ -42,12 +92,24 @@ export function buildCliDiagnosticEvent(diagnostic: CliDiagnostic) {
42
92
  ? { unsupported_api: bounded(diagnostic.unsupportedApi, 160) }
43
93
  : {}),
44
94
  }
45
- : { error_class: bounded(diagnostic.errorClass, 160) }
95
+ : diagnostic.kind === 'error_shape'
96
+ ? {
97
+ error_class: bounded(diagnostic.errorClass, 160),
98
+ message: sanitizeDiagnosticMessage(diagnostic.message),
99
+ }
100
+ : { message: sanitizeDiagnosticMessage(diagnostic.message) }
46
101
 
47
102
  return {
48
103
  event: diagnostic.kind,
49
- distinct_id: 'peach-cli-diagnostic',
50
- properties,
104
+ distinct_id: readOrCreateAnonymousId(),
105
+ properties: {
106
+ cli_version: diagnosticVersion(getCliVersion()),
107
+ runtime_version: observedRuntimeVersion ?? diagnosticVersion(readActiveRuntime()),
108
+ os: process.platform,
109
+ arch: process.arch,
110
+ command: parseArgs(process.argv).command ?? 'open',
111
+ ...properties,
112
+ },
51
113
  }
52
114
  }
53
115
 
@@ -55,12 +117,20 @@ const queue: CliDiagnostic[] = []
55
117
 
56
118
  export function trackCliDiagnostic(diagnostic: CliDiagnostic): void {
57
119
  if (!diagnosticsAllowedForCurrentProcess()) return
58
- queue.push(diagnostic)
120
+ if (queue.length >= 50) return
121
+ const entry =
122
+ diagnostic.kind === 'package_incompatibility'
123
+ ? diagnostic
124
+ : { ...diagnostic, message: sanitizeDiagnosticMessage(diagnostic.message) }
125
+ if (queue.some((queued) => JSON.stringify(queued) === JSON.stringify(entry))) return
126
+ queue.push(entry)
59
127
  }
60
128
 
61
129
  export function trackCliError(error: unknown): void {
62
130
  const errorClass = error instanceof Error ? error.constructor.name : typeof error
63
- trackCliDiagnostic({ kind: 'error_shape', errorClass })
131
+ const message =
132
+ error instanceof Error ? error.message : typeof error === 'string' ? error : ''
133
+ trackCliDiagnostic({ kind: 'error_shape', errorClass, message })
64
134
  }
65
135
 
66
136
  export async function flushCliTelemetry(): Promise<void> {
@@ -70,8 +140,9 @@ export async function flushCliTelemetry(): Promise<void> {
70
140
  queue.length = 0
71
141
  return
72
142
  }
73
- const batch = queue.splice(0, queue.length).map(buildCliDiagnosticEvent)
143
+ const diagnostics = queue.splice(0, queue.length)
74
144
  try {
145
+ const batch = diagnostics.map((diagnostic) => buildCliDiagnosticEvent(diagnostic))
75
146
  await fetch(`${config.host}/batch/`, {
76
147
  method: 'POST',
77
148
  headers: { 'content-type': 'application/json' },
package/detox/index.ts CHANGED
@@ -10,6 +10,11 @@ import {
10
10
  readBrowserCompositeCaptureRequest,
11
11
  resolveBrowserCompositeCdpClip,
12
12
  } from '../../peach-engine/src/capture/browser-composite'
13
+ import {
14
+ AUTH_BROWSER_EXTERNAL_NAVIGATION_EVENT,
15
+ AUTH_BROWSER_SESSION_FRAGMENT_KEY,
16
+ forwardPopupSchemeNavigations,
17
+ } from '../../peach-engine/src/open-external'
13
18
  import { isAppBootTargetUrl } from '../../peach-engine/src/render-worker/app-load'
14
19
  import { SOOTSIM_SHELL_COMMAND_NAME } from '../src/menu'
15
20
  import { pollUntil } from '../src/poll-until'
@@ -55,7 +60,6 @@ let _reapBrowserNow: (() => void) | null = null
55
60
  let _context: BrowserContext | null = null
56
61
  let _page: Page | null = null
57
62
  let _synchronizationEnabled = false
58
- let _suspendedAppId: string | null = null
59
63
  let _browserCompromised: string | null = null
60
64
  let browserIdentity = 0
61
65
  let contextIdentity = 0
@@ -601,7 +605,7 @@ export async function waitForSootsimIdle(
601
605
  // find a node in the sootsim tree matching a matcher descriptor
602
606
  async function findNodeByMatcher(matcher: Matcher): Promise<any> {
603
607
  const page = getPage()
604
- if (matcher.index !== undefined) {
608
+ if (matcher.index !== undefined || matcher.type === 'text') {
605
609
  return page.evaluate(async (indexedMatcher: Matcher) => {
606
610
  const filter =
607
611
  indexedMatcher.type === 'id'
@@ -614,8 +618,8 @@ async function findNodeByMatcher(matcher: Matcher): Promise<any> {
614
618
  ? { hasRole: indexedMatcher.value }
615
619
  : { type: indexedMatcher.value }
616
620
  const results = await window.__sootsimTest!.queryAll(filter)
617
- // detox indexes by.text over the text elements that read exactly that
618
- // text. hasText also matches every ancestor, so index 0 would be the root.
621
+ // detox by.text matches exact text elements, with or without an index.
622
+ // hasText also matches ancestors and substrings, which are not targets.
619
623
  const indexed =
620
624
  indexedMatcher.type === 'text'
621
625
  ? results.filter(
@@ -630,11 +634,6 @@ async function findNodeByMatcher(matcher: Matcher): Promise<any> {
630
634
  (id: string) => window.__sootsimTest!.findByTestId(id),
631
635
  matcher.value,
632
636
  )
633
- } else if (matcher.type === 'text') {
634
- return page.evaluate(
635
- (text: string) => window.__sootsimTest!.findByText(text),
636
- matcher.value,
637
- )
638
637
  } else if (matcher.type === 'label') {
639
638
  return page.evaluate(
640
639
  (label: string) => window.__sootsimTest!.findByLabel(label),
@@ -933,7 +932,22 @@ async function dispatchDeviceOrientation(orientation: 'portrait' | 'landscape')
933
932
  landscapeSide: DETOX_PLATFORM === 'ios' ? 'right' : 'left',
934
933
  },
935
934
  )
936
- await page.waitForTimeout(50)
935
+ // destination dimensions publish before the interface finishes turning.
936
+ await page.waitForFunction(
937
+ (next) => {
938
+ const settings = window.SootSim?.bridges?.settings?.get?.()
939
+ const rotation = Reflect.get(window, '__sootsimDeviceRotation')
940
+ return (
941
+ typeof settings === 'object' &&
942
+ settings !== null &&
943
+ 'orientation' in settings &&
944
+ settings.orientation === next &&
945
+ rotation?.active !== true
946
+ )
947
+ },
948
+ orientation,
949
+ { timeout: 15000 },
950
+ )
937
951
  }
938
952
 
939
953
  const MAX_SETTLED_SPEED_PT_PER_SEC = 2.0
@@ -1584,14 +1598,18 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1584
1598
  visible: node.layout.width > 0 && node.layout.height > 0,
1585
1599
  enabled: !node.accessibilityState?.disabled,
1586
1600
  ...node,
1587
- frame: node.absolutePosition
1588
- ? {
1589
- x: node.absolutePosition.x,
1590
- y: node.absolutePosition.y,
1591
- width: node.absolutePosition.width ?? node.frame?.width ?? 0,
1592
- height: node.absolutePosition.height ?? node.frame?.height ?? 0,
1593
- }
1594
- : node.frame,
1601
+ // the transformed rect when the node reports one; a node resolved
1602
+ // without a size (x and y only) keeps its own frame
1603
+ frame:
1604
+ node.absolutePosition?.width !== undefined &&
1605
+ node.absolutePosition.height !== undefined
1606
+ ? {
1607
+ x: node.absolutePosition.x,
1608
+ y: node.absolutePosition.y,
1609
+ width: node.absolutePosition.width,
1610
+ height: node.absolutePosition.height,
1611
+ }
1612
+ : node.frame,
1595
1613
  }
1596
1614
  },
1597
1615
 
@@ -1755,36 +1773,41 @@ export const device = {
1755
1773
  if (_browserCompromised) {
1756
1774
  throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
1757
1775
  }
1758
- if (
1759
- _page &&
1760
- opts?.newInstance === false &&
1761
- !opts.delete &&
1762
- !opts.recordVideoDir &&
1763
- _suspendedAppId
1764
- ) {
1776
+ if (_page && opts?.newInstance === false && !opts.delete && !opts.recordVideoDir) {
1777
+ // detox activates the running app without relaunching it; a url would
1778
+ // need a delivery path into the running guest that the adapter lacks.
1779
+ if (opts.url) {
1780
+ throw new Error(`SootSim cannot deliver a url to a running app: ${opts.url}`)
1781
+ }
1765
1782
  const page = _page
1766
- const appId = _suspendedAppId
1767
- await page.evaluate(
1768
- (id) => window.SootSim?.bridges?.mainShell?.launchApp?.(id),
1769
- appId,
1770
- )
1771
- await pollUntil(
1772
- () =>
1773
- page.evaluate(async (id) => {
1774
- const state = await window.SootSim?.bridges?.mainShell?.getState?.()
1775
- return (
1776
- state?.state === 'app' &&
1777
- state.activeApp === id &&
1778
- state.switcherPhase === 'idle'
1779
- )
1780
- }, appId),
1781
- Boolean,
1782
- {
1783
- timeoutMs: 10000,
1784
- label: `SootSim app ${appId} to resume`,
1785
- },
1786
- )
1787
- _suspendedAppId = null
1783
+ const appId = await page.evaluate(async () => {
1784
+ const state = await window.SootSim?.bridges?.mainShell?.getState?.()
1785
+ const guestApp = state?.guestApp
1786
+ if (typeof guestApp !== 'string') {
1787
+ throw new Error('SootSim main shell reports no guest app')
1788
+ }
1789
+ if (state?.state === 'app' && state.activeApp === guestApp) return null
1790
+ window.SootSim?.bridges?.mainShell?.launchApp?.(guestApp)
1791
+ return guestApp
1792
+ })
1793
+ if (appId !== null) {
1794
+ await pollUntil(
1795
+ () =>
1796
+ page.evaluate(async (id) => {
1797
+ const state = await window.SootSim?.bridges?.mainShell?.getState?.()
1798
+ return (
1799
+ state?.state === 'app' &&
1800
+ state.activeApp === id &&
1801
+ state.switcherPhase === 'idle'
1802
+ )
1803
+ }, appId),
1804
+ Boolean,
1805
+ {
1806
+ timeoutMs: 10000,
1807
+ label: `SootSim app ${appId} to resume`,
1808
+ },
1809
+ )
1810
+ }
1788
1811
  await waitForSootsimTree(page, 10000)
1789
1812
  if (opts?.userNotification) {
1790
1813
  await postRemotePushToSimulator(opts.userNotification, true)
@@ -1833,6 +1856,11 @@ export const device = {
1833
1856
  reapOwnedBrowser()
1834
1857
  throw error
1835
1858
  }
1859
+ await forwardPopupSchemeNavigations(
1860
+ _context,
1861
+ AUTH_BROWSER_EXTERNAL_NAVIGATION_EVENT,
1862
+ AUTH_BROWSER_SESSION_FRAGMENT_KEY,
1863
+ )
1836
1864
  await _context.exposeBinding('__sootsimHostCapture', async (source, value) => {
1837
1865
  const request = readBrowserCompositeCaptureRequest(value)
1838
1866
  const session = await source.page.context().newCDPSession(source.page)
@@ -3178,14 +3206,12 @@ export const device = {
3178
3206
 
3179
3207
  async sendToHome() {
3180
3208
  const page = getPage()
3181
- _suspendedAppId = await page.evaluate(async () => {
3209
+ await page.evaluate(async () => {
3182
3210
  const state = await window.SootSim?.bridges?.mainShell?.getState?.()
3183
- const appId = state?.activeApp
3184
- if (typeof appId !== 'string') {
3211
+ if (typeof state?.activeApp !== 'string') {
3185
3212
  throw new Error(`cannot send an inactive SootSim app home`)
3186
3213
  }
3187
3214
  window.SootSim?.bridges?.mainShell?.goHome?.()
3188
- return appId
3189
3215
  })
3190
3216
  await pollUntil(
3191
3217
  () =>
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.629 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.631 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.629 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.631 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.629 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.631 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.629 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.631 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.629 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.631 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.629 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.631 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.629 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.631 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.629 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.631 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.629 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.631 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/beta.ts
4
4
  var IS_BETA = true;