peachsim 0.1.627 → 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 (92) hide show
  1. package/LICENSE +1 -1
  2. package/cli/bin.ts +6 -2
  3. package/cli/bridge-flow-runner.ts +79 -72
  4. package/cli/commands/cleanup.ts +6 -6
  5. package/cli/commands/control.ts +4 -5
  6. package/cli/commands/detox.ts +25 -11
  7. package/cli/commands/flow.ts +3 -2
  8. package/cli/commands/inspect.ts +22 -37
  9. package/cli/commands/mode.ts +11 -7
  10. package/cli/commands/report-issue.ts +1 -1
  11. package/cli/commands/setup.ts +16 -11
  12. package/cli/commands/show-touches-mode.ts +33 -0
  13. package/cli/commands/skills.ts +16 -0
  14. package/cli/commands/test.ts +8 -1
  15. package/cli/commands/welcome.ts +3 -3
  16. package/cli/drivers/playwright-sim-host.ts +6 -5
  17. package/cli/help-core.ts +9 -4
  18. package/cli/internal-child.ts +14 -2
  19. package/cli/levenshtein.ts +27 -0
  20. package/cli/main.ts +28 -18
  21. package/cli/ws-bridge.ts +3 -2
  22. package/detox/index.ts +234 -7
  23. package/dist-lib/agent-daemon-client.cjs +1 -1
  24. package/dist-lib/agent-events.cjs +1 -1
  25. package/dist-lib/agent-identity.cjs +1 -1
  26. package/dist-lib/agent-sessions.cjs +1 -1
  27. package/dist-lib/attached-projects.cjs +1 -1
  28. package/dist-lib/auth/shared-session.cjs +1 -1
  29. package/dist-lib/backend-origin.cjs +1 -1
  30. package/dist-lib/beta.cjs +1 -1
  31. package/dist-lib/beta.mjs +1 -1
  32. package/dist-lib/bridge-constants.cjs +1 -1
  33. package/dist-lib/bridge-contract-input.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.mjs +1 -1
  35. package/dist-lib/bridge-contract.cjs +1 -1
  36. package/dist-lib/bridge-contract.mjs +1 -1
  37. package/dist-lib/capture-contract.cjs +1 -1
  38. package/dist-lib/capture-contract.mjs +1 -1
  39. package/dist-lib/cli-constants.cjs +1 -1
  40. package/dist-lib/cloud-contract.cjs +1 -1
  41. package/dist-lib/cloud-contract.mjs +1 -1
  42. package/dist-lib/cloud-test-args.cjs +1 -1
  43. package/dist-lib/cloud-test-args.mjs +1 -1
  44. package/dist-lib/cloud.cjs +1 -1
  45. package/dist-lib/cloud.mjs +1 -1
  46. package/dist-lib/config.cjs +1 -1
  47. package/dist-lib/detox/index.cjs +170 -13
  48. package/dist-lib/dev-bundle-resolution.cjs +2 -2
  49. package/dist-lib/home-paths.cjs +1 -1
  50. package/dist-lib/host/bridge-host.cjs +42 -37
  51. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  52. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  53. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  54. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  55. package/dist-lib/host/websocket-proxy.cjs +1 -1
  56. package/dist-lib/index.cjs +44 -5
  57. package/dist-lib/jump-to-source-babel.cjs +1 -1
  58. package/dist-lib/jump-to-source-native.cjs +1 -1
  59. package/dist-lib/menu.cjs +7 -1
  60. package/dist-lib/menu.mjs +7 -1
  61. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  62. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  63. package/dist-lib/metro-production-bundle.cjs +1 -1
  64. package/dist-lib/metro-production-bundle.mjs +1 -1
  65. package/dist-lib/metro.cjs +43 -4
  66. package/dist-lib/profiles.cjs +1 -1
  67. package/dist-lib/public-brand.cjs +1 -1
  68. package/dist-lib/react-native-host-modules.cjs +1 -1
  69. package/dist-lib/react-native-host-modules.mjs +1 -1
  70. package/dist-lib/render-mode.cjs +1 -1
  71. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  72. package/dist-lib/sdk.cjs +1 -1
  73. package/dist-lib/sdk.mjs +1 -1
  74. package/dist-lib/skills.cjs +140 -149
  75. package/dist-lib/swift-compile.cjs +1 -1
  76. package/dist-lib/swift-compile.mjs +1 -1
  77. package/dist-lib/swift-package.cjs +1 -1
  78. package/dist-lib/swift-package.mjs +1 -1
  79. package/dist-lib/swift-submit.cjs +1 -1
  80. package/dist-lib/swift-submit.mjs +1 -1
  81. package/dist-lib/swift.cjs +7234 -7069
  82. package/dist-lib/vite-kotlin.cjs +1 -1
  83. package/dist-lib/vite-swift.cjs +1 -1
  84. package/dist-lib/vite.cjs +43 -4
  85. package/package.json +1 -1
  86. package/skills/contrast/SKILL.md +1 -1
  87. package/src/dev-bundle-resolution.ts +1 -1
  88. package/src/host/bridge-host.ts +2 -34
  89. package/src/menu.ts +7 -0
  90. package/src/native-seam-manifest.ts +4 -16
  91. package/src/runtime-assets.ts +59 -2
  92. package/src/vite-plugin.ts +723 -25
@@ -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`),
@@ -984,7 +984,14 @@ export async function runTest(
984
984
  return runCloudTest(args, opts)
985
985
  }
986
986
 
987
- 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
+ }
988
995
  const app = appFromArgs(args)
989
996
  const appTarget = app
990
997
  ? await resolveTestAppTarget(app)
@@ -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
  }
@@ -446,16 +446,17 @@ const cleanupProfile = () => {
446
446
  const request = readBrowserCompositeCaptureRequest(value);
447
447
  const session = await source.page.context().newCDPSession(source.page);
448
448
  try {
449
+ const { clip, originX, originY } = resolveBrowserCompositeCdpClip(
450
+ request,
451
+ await session.send('Page.getLayoutMetrics'),
452
+ );
449
453
  const result = await session.send('Page.captureScreenshot', {
450
454
  format: 'png',
451
455
  fromSurface: true,
452
456
  captureBeyondViewport: true,
453
- clip: resolveBrowserCompositeCdpClip(
454
- request,
455
- await session.send('Page.getLayoutMetrics'),
456
- ),
457
+ clip,
457
458
  });
458
- return 'data:image/png;base64,' + result.data;
459
+ return { dataUrl: 'data:image/png;base64,' + result.data, originX, originY };
459
460
  } finally {
460
461
  await session.detach();
461
462
  }
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)}
@@ -18,6 +18,12 @@ import * as vm from 'node:vm'
18
18
 
19
19
  export const RNX_INTERNAL_COMMAND = '__internal'
20
20
 
21
+ // playwright's installer forks its downloader script with process.execPath,
22
+ // which here is this binary. the installer child sets this for its whole
23
+ // process tree, and bin.ts then runs a forked `.js` argument as a script
24
+ // instead of reading it as a peach command.
25
+ export const RNX_FORKED_SCRIPT_ENV = 'PEACH_RUNS_FORKED_SCRIPTS'
26
+
21
27
  export const RNX_INTERNAL_CHILDREN = {
22
28
  playwrightProbe: 'playwright-probe',
23
29
  playwrightInstall: 'playwright-install',
@@ -113,8 +119,14 @@ function playwrightInstall(args: string[]): void {
113
119
  if (!cliPath) {
114
120
  throw new Error('internal playwright-install needs the package CLI path')
115
121
  }
116
- process.argv = [process.argv[0], cliPath, ...cliArgs]
117
- requireFromCwd()(cliPath)
122
+ process.env[RNX_FORKED_SCRIPT_ENV] = '1'
123
+ runScript(cliPath, cliArgs)
124
+ }
125
+
126
+ /** run a javascript file as node would, with argv shaped for its own parser. */
127
+ export function runScript(scriptPath: string, args: string[]): void {
128
+ process.argv = [process.argv[0], scriptPath, ...args]
129
+ createRequire(scriptPath)(scriptPath)
118
130
  }
119
131
 
120
132
  /** the detached browser host. its source is CJS that resolves playwright from
@@ -0,0 +1,27 @@
1
+ export function levenshtein(a: string, b: string): number {
2
+ if (a === b) return 0
3
+ if (!a.length) return b.length
4
+ if (!b.length) return a.length
5
+ // standard two-row dp: prev[] is the previous row, cur[] is the row being
6
+ // computed; swap pointers per outer iteration. the earlier in-place rolling
7
+ // version overwrote prev[j-1] twice per cell and never set prev[0]=i, so
8
+ // distance(`kitten`,`sitting`) returned 8 instead of 3 and the fuzzy-suggest
9
+ // ranking was effectively random.
10
+ let prev = new Array<number>(b.length + 1)
11
+ let cur = new Array<number>(b.length + 1)
12
+ for (let j = 0; j <= b.length; j++) prev[j] = j
13
+ for (let i = 1; i <= a.length; i++) {
14
+ cur[0] = i
15
+ for (let j = 1; j <= b.length; j++) {
16
+ cur[j] = Math.min(
17
+ prev[j] + 1,
18
+ cur[j - 1] + 1,
19
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
20
+ )
21
+ }
22
+ const tmp = prev
23
+ prev = cur
24
+ cur = tmp
25
+ }
26
+ return prev[b.length]
27
+ }
package/cli/main.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  // when none of its hidden entry points claimed the process.
4
4
 
5
5
  import { rnxPublicBrand } from '../src/public-brand'
6
- import { parseArgs, TOP_LEVEL_RUNTIME_COMMANDS } from './parse-args'
6
+ import { levenshtein } from './levenshtein'
7
+ import { COMMANDS, parseArgs, TOP_LEVEL_RUNTIME_COMMANDS } from './parse-args'
7
8
  import { RnxExit } from './run-rnx'
8
9
  import { rnxSelfInvocation } from './self-invocation'
9
10
  import { IS_STANDALONE } from './standalone'
@@ -37,18 +38,29 @@ function handleTopLevelError(err: unknown): never {
37
38
  msg.startsWith('peach bridge exited before becoming ready') ||
38
39
  msg.startsWith(`${rnxPublicBrand.commandName} open: requested bridge port`) ||
39
40
  msg.startsWith('background daemon setup is unavailable') ||
40
- msg.startsWith('peach bridge did not start within')
41
+ msg.startsWith('peach bridge did not start within') ||
42
+ msg.startsWith('could not resolve a native bundle for ')
41
43
  if (isKnownCliFailure && !process.env.SOOTSIM_VERBOSE) {
42
44
  process.stderr.write(` ${msg}\n`)
43
45
  process.exit(1)
44
46
  }
45
- if (err instanceof Error && err.stack) {
46
- process.stderr.write(`${err.stack}\n`)
47
- } else {
48
- process.stderr.write(`${msg}\n`)
49
- }
47
+ // bun can render an async-thrown error's stack with a bare `Error` header, so
48
+ // the message always goes first.
49
+ process.stderr.write(` ${msg}\n`)
50
+ if (err instanceof Error && err.stack) process.stderr.write(`${err.stack}\n`)
50
51
  process.exit(1)
51
52
  }
53
+ function printUnknownCommand(attempted: string) {
54
+ const closest = [...COMMANDS]
55
+ .map((name) => ({ name, distance: levenshtein(attempted, name) }))
56
+ .sort((a, b) => a.distance - b.distance)[0]
57
+ console.error(` unknown command: ${attempted}`)
58
+ if (closest && closest.distance <= 2) {
59
+ console.error(` did you mean \`${rnxPublicBrand.commandName} ${closest.name}\`?`)
60
+ }
61
+ console.error(` run \`${rnxPublicBrand.commandName} --help\` to see the full surface.`)
62
+ }
63
+
52
64
  process.on('unhandledRejection', handleTopLevelError)
53
65
  process.on('uncaughtException', handleTopLevelError)
54
66
 
@@ -73,7 +85,11 @@ const isCliUpdateWorker =
73
85
  parsed.commandArgs[0] === '--check-worker'
74
86
  const isBareWelcome = !parsed.command && parsed.commandArgs.length === 0
75
87
  const isReadOnlyInvocation =
76
- parsed.version || parsed.help || parsed.commandArgs.includes('--dry-run')
88
+ parsed.version ||
89
+ parsed.help ||
90
+ parsed.commandArgs.includes('--help') ||
91
+ parsed.commandArgs.includes('-h') ||
92
+ parsed.commandArgs.includes('--dry-run')
77
93
 
78
94
  if (isCliUpdateWorker) {
79
95
  if (IS_STANDALONE) {
@@ -264,7 +280,7 @@ if (device || theme) {
264
280
  // returning runs open ConnectRN directly; app discovery belongs inside it.
265
281
  if (!parsed.command && parsed.commandArgs.length === 0) {
266
282
  const { runWelcome } = await import('./commands/welcome')
267
- await exitWithFlush(await runWelcome())
283
+ await exitWithFlush(await runWelcome(port))
268
284
  }
269
285
 
270
286
  // route to command. an empty command at this point only happens if the
@@ -280,10 +296,7 @@ if (!command) {
280
296
  if (parsed.commandArgs.length > 0) {
281
297
  const attempted =
282
298
  parsed.commandArgs.find((a) => !a.startsWith('-')) ?? parsed.commandArgs[0]
283
- console.error(` unknown command: ${attempted}`)
284
- console.error(
285
- ` run \`${rnxPublicBrand.commandName} --help\` to see the full surface.`,
286
- )
299
+ printUnknownCommand(attempted)
287
300
  await exitWithFlush(1)
288
301
  }
289
302
  const { printHelp } = await import('./help')
@@ -608,7 +621,7 @@ try {
608
621
 
609
622
  case 'setup': {
610
623
  const { runSetup } = await import('./commands/setup')
611
- await runSetup(parsed.commandArgs)
624
+ await runSetup(parsed.commandArgs, { port })
612
625
  break
613
626
  }
614
627
 
@@ -685,10 +698,7 @@ try {
685
698
  }
686
699
 
687
700
  default: {
688
- console.error(` unknown command: ${command}`)
689
- console.error(
690
- ` run \`${rnxPublicBrand.commandName} --help\` to see the full surface.`,
691
- )
701
+ printUnknownCommand(command)
692
702
  await exitWithFlush(1)
693
703
  }
694
704
  }
package/cli/ws-bridge.ts CHANGED
@@ -145,9 +145,10 @@ function resolveDefaultDevBridgeLockfile(): DevBridgeLockfile | null {
145
145
  }
146
146
  }
147
147
 
148
+ // another checkout's bridge on its own offset belongs to that checkout's
149
+ // world; only the canonical bridge is shared, and past it the daemon serves.
148
150
  return (
149
- lockfiles.find((lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT) ??
150
- lockfiles[0]
151
+ lockfiles.find((lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT) ?? null
151
152
  )
152
153
  }
153
154
 
package/detox/index.ts CHANGED
@@ -369,6 +369,52 @@ function reapOwnedBrowser(): void {
369
369
  } catch {}
370
370
  }
371
371
 
372
+ // an open picture-in-picture window keeps the browser context alive and
373
+ // context.close() never settles (expo-pip has no exit api, and the OS
374
+ // window outlives its video element). exit PiP in every page first; the
375
+ // page's leavepictureinpicture handler stops the capture tracks and
376
+ // removes the video. bounded and best-effort so a wedged page cannot eat
377
+ // the close budget.
378
+ const CONTEXT_MEDIA_QUIESCE_TIMEOUT_MS = 2_500
379
+
380
+ async function quiesceContextMedia(context: BrowserContext): Promise<void> {
381
+ const pages = context.pages()
382
+ if (pages.length === 0) return
383
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
384
+ await Promise.race([
385
+ Promise.all(
386
+ pages.map((page) =>
387
+ page
388
+ .evaluate(() => {
389
+ const doc = globalThis.document
390
+ if (!doc) return
391
+ if (
392
+ doc.pictureInPictureElement &&
393
+ typeof doc.exitPictureInPicture === 'function'
394
+ ) {
395
+ void doc.exitPictureInPicture().catch(() => {})
396
+ }
397
+ doc.querySelectorAll('video').forEach((v) => {
398
+ try {
399
+ if (
400
+ v.srcObject &&
401
+ typeof (v.srcObject as MediaStream).getTracks === 'function'
402
+ ) {
403
+ ;(v.srcObject as MediaStream).getTracks().forEach((t) => t.stop())
404
+ v.srcObject = null
405
+ }
406
+ v.pause()
407
+ v.remove()
408
+ } catch {}
409
+ })
410
+ })
411
+ .catch(() => {}),
412
+ ),
413
+ ),
414
+ sleep(CONTEXT_MEDIA_QUIESCE_TIMEOUT_MS),
415
+ ]).catch(() => {})
416
+ }
417
+
372
418
  async function closeContext() {
373
419
  if (_browserCompromised) {
374
420
  throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
@@ -377,6 +423,7 @@ async function closeContext() {
377
423
  const context = _context
378
424
  const closingContextId = contextIdentity
379
425
  try {
426
+ await quiesceContextMedia(context)
380
427
  await withLifecycleTimeout('context.close()', closingContextId, () => context.close())
381
428
  _context = null
382
429
  _page = null
@@ -715,7 +762,9 @@ async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
715
762
  // would edit around a caret the tap then moves, so wait for its focus.
716
763
  await pollUntil(
717
764
  () =>
718
- page.evaluate(async () => (await window.__sootsimTest!.getFocusedNode?.())?.nodeId ?? null),
765
+ page.evaluate(
766
+ async () => (await window.__sootsimTest!.getFocusedNode?.())?.nodeId ?? null,
767
+ ),
719
768
  (focusedNodeId) => focusedNodeId === node.nodeId,
720
769
  {
721
770
  timeoutMs: 5000,
@@ -1535,6 +1584,14 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
1535
1584
  visible: node.layout.width > 0 && node.layout.height > 0,
1536
1585
  enabled: !node.accessibilityState?.disabled,
1537
1586
  ...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,
1538
1595
  }
1539
1596
  },
1540
1597
 
@@ -1552,6 +1609,131 @@ export function element(matcher: Matcher): SootSimElement {
1552
1609
  }
1553
1610
 
1554
1611
  // device object -- app lifecycle
1612
+ export interface DetoxUserNotificationParams {
1613
+ trigger: {
1614
+ type: string
1615
+ [key: string]: unknown
1616
+ }
1617
+ title?: unknown
1618
+ subtitle?: unknown
1619
+ body?: unknown
1620
+ badge?: unknown
1621
+ category?: unknown
1622
+ 'content-available'?: unknown
1623
+ payload?: Record<string, unknown>
1624
+ 'user-text'?: unknown
1625
+ 'action-identifier'?: unknown
1626
+ [key: string]: unknown
1627
+ }
1628
+
1629
+ // detox's documented sendUserNotification data, converted the way detox ios
1630
+ // builds it (DetoxUserNotificationDispatcher.payload): the user info is the
1631
+ // custom `payload`, with the top-level title, subtitle, and body written into
1632
+ // its aps alert and badge, category, and content-available written into its
1633
+ // aps. a top-level key that is absent removes the payload's own value, as
1634
+ // assigning nil to a swift dictionary does; every other aps key survives.
1635
+ function detoxNotificationToApnsPayload(params: DetoxUserNotificationParams): {
1636
+ payload: Record<string, unknown>
1637
+ actionId?: string
1638
+ userText?: string
1639
+ } {
1640
+ const trigger = objectOrEmpty(objectOrEmpty(params).trigger)
1641
+ const supported = ['push', 'calendar', 'location', 'timeInterval']
1642
+ if (typeof trigger.type !== 'string' || !supported.includes(trigger.type)) {
1643
+ throw new Error(
1644
+ `Missing trigger or invalid trigger type. A 'trigger' key must exist, with one of the following types: '${supported.join("', '")}'`,
1645
+ )
1646
+ }
1647
+ if (trigger.type !== 'push') {
1648
+ throw new Error(
1649
+ `sootsim device.sendUserNotification: trigger type '${trigger.type}' is not simulated, only 'push'`,
1650
+ )
1651
+ }
1652
+ const assign = (record: Record<string, unknown>, key: string, value: unknown) => {
1653
+ if (value === undefined) delete record[key]
1654
+ else record[key] = value
1655
+ }
1656
+ const payload = { ...objectOrEmpty(params.payload) }
1657
+ const aps = { ...objectOrEmpty(payload.aps) }
1658
+ const alert = { ...objectOrEmpty(aps.alert) }
1659
+ assign(alert, 'title', params.title)
1660
+ assign(alert, 'subtitle', params.subtitle)
1661
+ assign(alert, 'body', params.body)
1662
+ aps.alert = alert
1663
+ assign(aps, 'badge', params.badge)
1664
+ assign(aps, 'category', params.category)
1665
+ assign(aps, 'content-available', params['content-available'])
1666
+ payload.aps = aps
1667
+ return {
1668
+ payload,
1669
+ actionId:
1670
+ typeof params['action-identifier'] === 'string'
1671
+ ? params['action-identifier']
1672
+ : undefined,
1673
+ userText: typeof params['user-text'] === 'string' ? params['user-text'] : undefined,
1674
+ }
1675
+ }
1676
+
1677
+ function objectOrEmpty(value: unknown): Record<string, unknown> {
1678
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
1679
+ ? { ...value }
1680
+ : {}
1681
+ }
1682
+
1683
+ interface PreparedRemotePush {
1684
+ payload: Record<string, unknown>
1685
+ actionId?: string
1686
+ userText?: string
1687
+ }
1688
+
1689
+ async function postPreparedRemotePush(
1690
+ prepared: PreparedRemotePush,
1691
+ launch: boolean,
1692
+ ): Promise<void> {
1693
+ const { payload, actionId, userText } = prepared
1694
+ const page = getPage()
1695
+ await page.evaluate(
1696
+ ({ request }) => {
1697
+ const bridges = (
1698
+ window as unknown as {
1699
+ SootSim?: {
1700
+ bridges?: {
1701
+ shellHost?: { postNativeUIRequest?: (request: unknown) => unknown }
1702
+ }
1703
+ }
1704
+ }
1705
+ ).SootSim?.bridges
1706
+ // the shell host queues requests until the shell worker connects, so a
1707
+ // post here never silently drops. (mainShell.postNativeUIRequest would
1708
+ // swallow a not-yet-connected shell and report success.)
1709
+ const target = bridges?.shellHost
1710
+ const post = target?.postNativeUIRequest
1711
+ if (typeof post !== 'function') {
1712
+ throw new Error(
1713
+ 'sootsim device.sendUserNotification: simulator shell unavailable — call device.launchApp() first',
1714
+ )
1715
+ }
1716
+ post.call(target, request)
1717
+ },
1718
+ {
1719
+ request: {
1720
+ kind: 'notification.remote-push',
1721
+ payload,
1722
+ launch,
1723
+ ...(actionId !== undefined ? { actionId } : {}),
1724
+ ...(userText !== undefined ? { userText } : {}),
1725
+ },
1726
+ },
1727
+ )
1728
+ }
1729
+
1730
+ async function postRemotePushToSimulator(
1731
+ params: DetoxUserNotificationParams,
1732
+ launch: boolean,
1733
+ ): Promise<void> {
1734
+ await postPreparedRemotePush(detoxNotificationToApnsPayload(params), launch)
1735
+ }
1736
+
1555
1737
  export const device = {
1556
1738
  _currentUrl: '',
1557
1739
  _platform: DETOX_PLATFORM as 'ios' | 'android',
@@ -1561,6 +1743,9 @@ export const device = {
1561
1743
  newInstance?: boolean
1562
1744
  url?: string
1563
1745
  launchArgs?: Record<string, any>
1746
+ // launch from a notification, like detox launchApp({ userNotification }):
1747
+ // dispatched during launch, so the app receives its tap response.
1748
+ userNotification?: DetoxUserNotificationParams
1564
1749
  // when set, the new BrowserContext records video at this dir. used by
1565
1750
  // captureProofMenuStages for the multi-stage animation diff path. must
1566
1751
  // be set at launchApp time because playwright recordVideo can't be
@@ -1601,6 +1786,9 @@ export const device = {
1601
1786
  )
1602
1787
  _suspendedAppId = null
1603
1788
  await waitForSootsimTree(page, 10000)
1789
+ if (opts?.userNotification) {
1790
+ await postRemotePushToSimulator(opts.userNotification, true)
1791
+ }
1604
1792
  return
1605
1793
  }
1606
1794
 
@@ -1649,16 +1837,17 @@ export const device = {
1649
1837
  const request = readBrowserCompositeCaptureRequest(value)
1650
1838
  const session = await source.page.context().newCDPSession(source.page)
1651
1839
  try {
1840
+ const { clip, originX, originY } = resolveBrowserCompositeCdpClip(
1841
+ request,
1842
+ await session.send('Page.getLayoutMetrics'),
1843
+ )
1652
1844
  const result = await session.send('Page.captureScreenshot', {
1653
1845
  format: 'png',
1654
1846
  fromSurface: true,
1655
1847
  captureBeyondViewport: true,
1656
- clip: resolveBrowserCompositeCdpClip(
1657
- request,
1658
- await session.send('Page.getLayoutMetrics'),
1659
- ),
1848
+ clip,
1660
1849
  })
1661
- return 'data:image/png;base64,' + result.data
1850
+ return { dataUrl: 'data:image/png;base64,' + result.data, originX, originY }
1662
1851
  } finally {
1663
1852
  await session.detach()
1664
1853
  }
@@ -1683,6 +1872,25 @@ export const device = {
1683
1872
  operation: 'launchApp',
1684
1873
  })
1685
1874
 
1875
+ if (opts?.userNotification) {
1876
+ // detox dispatches the launch notification before the app's js runs.
1877
+ // the shell and tenant channels queue it until they connect, so it is
1878
+ // posted once, as soon as the shell accepts requests.
1879
+ const prepared = detoxNotificationToApnsPayload(opts.userNotification)
1880
+ await pollUntil(
1881
+ () =>
1882
+ postPreparedRemotePush(prepared, true).then(
1883
+ () => true,
1884
+ () => false,
1885
+ ),
1886
+ Boolean,
1887
+ {
1888
+ timeoutMs: 60000,
1889
+ label: 'SootSim shell to accept launch notification',
1890
+ },
1891
+ )
1892
+ }
1893
+
1686
1894
  await waitForSootsimTree(_page!, 30000)
1687
1895
  await waitForSootsimSurfaceMetrics(_page!, 30000)
1688
1896
  await waitForSootsimShellReady(_page!, 30000)
@@ -1728,6 +1936,13 @@ export const device = {
1728
1936
  await waitForSootsimWorkletsIdle(page, 3000)
1729
1937
  },
1730
1938
 
1939
+ // detox device.sendUserNotification on ios: the notification goes straight
1940
+ // to the app's notification center delegate. an active app gets willPresent;
1941
+ // otherwise the app gets the tap response.
1942
+ async sendUserNotification(params: DetoxUserNotificationParams) {
1943
+ await postRemotePushToSimulator(params, false)
1944
+ },
1945
+
1731
1946
  async takeScreenshot(name: string): Promise<string> {
1732
1947
  return writeSootsimScreenshot(name)
1733
1948
  },
@@ -2832,8 +3047,20 @@ export const device = {
2832
3047
  // no-op for sootsim
2833
3048
  },
2834
3049
 
3050
+ // set the simulated device location at the platform level, the same step
3051
+ // native detox's setLocation takes: on iOS it drives the simulator location
3052
+ // (AppleSimUtils/simctl location set) and CLLocationManager delivers the fix
3053
+ // asynchronously. here the platform is the harness browser, so grant page
3054
+ // geolocation and override the context fix; the guest location seam observes
3055
+ // it through navigator.geolocation exactly like the app observes Core
3056
+ // Location. app permission state is untouched, matching native.
2835
3057
  async setLocation(lat: number, lon: number) {
2836
- // no-op
3058
+ const page = getPage()
3059
+ const context = _context ?? page.context()
3060
+ await context.grantPermissions(['geolocation'], {
3061
+ origin: new URL(page.url()).origin,
3062
+ })
3063
+ await context.setGeolocation({ latitude: lat, longitude: lon })
2837
3064
  },
2838
3065
 
2839
3066
  async setStatusBar(config: StatusBarConfig) {
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.628 | (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.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.628 | (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.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.628 | (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;