rnxsim 0.1.392 → 0.1.394

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 (61) hide show
  1. package/cli/cloud-client.ts +74 -52
  2. package/cli/cloud-dispatch.ts +2 -1
  3. package/cli/commands/inspect/core.ts +33 -2
  4. package/cli/commands/platform.ts +34 -9
  5. package/detox/attempt-events.cjs +23 -0
  6. package/detox/element-types.ts +11 -2
  7. package/detox/expectations.ts +7 -3
  8. package/detox/gestures.ts +23 -2
  9. package/detox/index.ts +42 -62
  10. package/detox/navigation-lifecycle.ts +290 -0
  11. package/dist-lib/agent-daemon-client.cjs +1 -1
  12. package/dist-lib/agent-events.cjs +1 -1
  13. package/dist-lib/agent-identity.cjs +1 -1
  14. package/dist-lib/agent-sessions.cjs +1 -1
  15. package/dist-lib/attached-projects.cjs +1 -1
  16. package/dist-lib/auth/shared-session.cjs +1 -1
  17. package/dist-lib/backend-origin.cjs +1 -1
  18. package/dist-lib/beta.cjs +1 -1
  19. package/dist-lib/beta.mjs +1 -1
  20. package/dist-lib/bridge-constants.cjs +1 -1
  21. package/dist-lib/bridge-contract-input.cjs +12 -1
  22. package/dist-lib/bridge-contract-input.mjs +10 -1
  23. package/dist-lib/bridge-contract.cjs +7 -1
  24. package/dist-lib/bridge-contract.mjs +5 -1
  25. package/dist-lib/capture-contract.cjs +1 -1
  26. package/dist-lib/capture-contract.mjs +1 -1
  27. package/dist-lib/cli-constants.cjs +1 -1
  28. package/dist-lib/cloud-contract.cjs +29 -3
  29. package/dist-lib/cloud-contract.mjs +26 -2
  30. package/dist-lib/config.cjs +1 -1
  31. package/dist-lib/detox/index.cjs +240 -48
  32. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  33. package/dist-lib/home-paths.cjs +1 -1
  34. package/dist-lib/host/bridge-host.cjs +1 -1
  35. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  36. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  37. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  38. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  39. package/dist-lib/host/websocket-proxy.cjs +1 -1
  40. package/dist-lib/index.cjs +23 -3
  41. package/dist-lib/jump-to-source-babel.cjs +1 -1
  42. package/dist-lib/menu.cjs +1 -1
  43. package/dist-lib/menu.mjs +1 -1
  44. package/dist-lib/metro-production-bundle.cjs +132 -3
  45. package/dist-lib/metro-production-bundle.mjs +129 -2
  46. package/dist-lib/metro.cjs +1 -1
  47. package/dist-lib/profiles.cjs +1 -1
  48. package/dist-lib/public-brand.cjs +1 -1
  49. package/dist-lib/react-native-host-modules.cjs +1 -1
  50. package/dist-lib/react-native-host-modules.mjs +1 -1
  51. package/dist-lib/render-mode.cjs +1 -1
  52. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  53. package/dist-lib/sdk.cjs +24 -3
  54. package/dist-lib/sdk.mjs +24 -3
  55. package/dist-lib/skills.cjs +51 -80
  56. package/dist-lib/vite.cjs +1 -1
  57. package/package.json +1 -1
  58. package/src/bridge-contract-input.ts +12 -0
  59. package/src/bridge-contract.ts +7 -0
  60. package/src/cloud-contract.ts +29 -0
  61. package/src/metro-production-bundle.ts +200 -0
@@ -1,8 +1,16 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
2
  import { closeSync, fstatSync, openSync, readFileSync } from 'node:fs'
3
- import { extname, resolve } from 'node:path'
4
- import { assertSelfContainedMetroArtifactAssets } from '../../contrast-bundler/src/metroAssetArtifact'
3
+ import { dirname, extname, join, resolve } from 'node:path'
5
4
  import {
5
+ embedMetroAssetFiles,
6
+ type MetroAssetVariant,
7
+ } from '../../contrast-bundler/src/metroAssetArtifact'
8
+ import {
9
+ buildRnxCloudArtifact,
10
+ type RnxCloudArtifactReceipt,
11
+ } from '../../contrast-bundler/src/metroCloudArtifact'
12
+ import {
13
+ isRnxCloudCommandType,
6
14
  RNX_CLOUD_MAX_ARTIFACT_BYTES,
7
15
  type RnxCloudCreateReceipt,
8
16
  } from '../src/cloud-contract'
@@ -27,6 +35,8 @@ interface CreateCloudSimulatorOptions {
27
35
  bundlePath: string
28
36
  device: string
29
37
  authorization: string
38
+ /** the `--assets-dest` directory Metro wrote beside the bundle. */
39
+ assetsPath?: string
30
40
  apiOrigin?: string
31
41
  }
32
42
 
@@ -61,9 +71,7 @@ function resolveCloudOrigin(value?: string): string {
61
71
  return normalizeCloudOrigin(source)
62
72
  }
63
73
 
64
- async function readImmutableBundle(
65
- bundlePath: string,
66
- ): Promise<{ bytes: Buffer; sha256: string }> {
74
+ function readImmutableBundleSource(bundlePath: string): string {
67
75
  const filepath = resolve(bundlePath)
68
76
  if (extname(filepath) !== '.js') {
69
77
  throw new Error('rnx ios --cloud requires one local Metro .js bundle')
@@ -77,11 +85,6 @@ async function readImmutableBundle(
77
85
  if (before.size <= 0n) {
78
86
  throw new Error('rnx ios --cloud bundle is empty')
79
87
  }
80
- if (before.size > BigInt(MAX_RNX_CLOUD_BUNDLE_BYTES)) {
81
- throw new Error(
82
- `rnx ios --cloud bundle exceeds the ${MAX_RNX_CLOUD_BUNDLE_BYTES}-byte limit`,
83
- )
84
- }
85
88
  const bytes = readFileSync(descriptor)
86
89
  const after = fstatSync(descriptor, { bigint: true })
87
90
  if (
@@ -102,16 +105,61 @@ async function readImmutableBundle(
102
105
  if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) {
103
106
  throw new Error('rnx ios --cloud requires a Metro JavaScript bundle')
104
107
  }
105
- await assertSelfContainedMetroArtifactAssets(source)
106
- return {
107
- bytes,
108
- sha256: createHash('sha256').update(bytes).digest('hex'),
109
- }
108
+ return source
110
109
  } finally {
111
110
  closeSync(descriptor)
112
111
  }
113
112
  }
114
113
 
114
+ // `react-native bundle --assets-dest <dir>` and `expo export:embed` write every
115
+ // scale variant with getAssetDestPathIOS: the descriptor's httpServerLocation
116
+ // without its leading slash, each `../` collapsed to `_`, then the asset name
117
+ // with its scale suffix. reading that layout is what lets an ordinary Metro
118
+ // build reach the cloud with no per-app script.
119
+ function metroAssetDestPath(assetsPath: string, variant: MetroAssetVariant): string {
120
+ const { descriptor, scale } = variant
121
+ const suffix = scale === 1 ? '' : `@${scale}x`
122
+ return join(
123
+ assetsPath,
124
+ descriptor.httpServerLocation.slice(1).replace(/\.\.\//g, '_'),
125
+ `${descriptor.name}${suffix}.${descriptor.type}`,
126
+ )
127
+ }
128
+
129
+ /**
130
+ * turns the Metro production bundle a caller built into the immutable artifact
131
+ * the runner executes: every asset scale is read from the asset graph and
132
+ * embedded, module identity is normalized, and the JavaScript is minified.
133
+ */
134
+ async function produceCloudArtifact(
135
+ bundlePath: string,
136
+ assetsPath: string,
137
+ ): Promise<{ bytes: Buffer; sha256: string; receipt: RnxCloudArtifactReceipt }> {
138
+ const source = readImmutableBundleSource(bundlePath)
139
+ const embedded = await embedMetroAssetFiles(source, async (variant) => {
140
+ const file = metroAssetDestPath(assetsPath, variant)
141
+ try {
142
+ return readFileSync(file)
143
+ } catch {
144
+ throw new Error(
145
+ `rnx ios --cloud could not read ${file} for ${variant.descriptor.name}.${variant.descriptor.type} at scale ${variant.scale}. Build the bundle with --assets-dest, or pass --assets <dir>.`,
146
+ )
147
+ }
148
+ })
149
+ const artifact = await buildRnxCloudArtifact(embedded.code)
150
+ const bytes = Buffer.from(artifact.code, 'utf8')
151
+ if (bytes.length > MAX_RNX_CLOUD_BUNDLE_BYTES) {
152
+ throw new Error(
153
+ `rnx ios --cloud artifact is ${bytes.length} bytes and exceeds the ${MAX_RNX_CLOUD_BUNDLE_BYTES}-byte limit`,
154
+ )
155
+ }
156
+ return {
157
+ bytes,
158
+ sha256: createHash('sha256').update(bytes).digest('hex'),
159
+ receipt: artifact.receipt,
160
+ }
161
+ }
162
+
115
163
  function readCreateResponse(
116
164
  value: unknown,
117
165
  expectedBytes: number,
@@ -155,9 +203,16 @@ function readCreateResponse(
155
203
 
156
204
  export async function createCloudSimulator(
157
205
  options: CreateCloudSimulatorOptions,
158
- ): Promise<{ receipt: RnxCloudCreateReceipt; session: CloudSession }> {
206
+ ): Promise<{
207
+ receipt: RnxCloudCreateReceipt
208
+ session: CloudSession
209
+ artifact: RnxCloudArtifactReceipt
210
+ }> {
159
211
  const apiOrigin = resolveCloudOrigin(options.apiOrigin)
160
- const artifact = await readImmutableBundle(options.bundlePath)
212
+ const artifact = await produceCloudArtifact(
213
+ options.bundlePath,
214
+ resolve(options.assetsPath ?? dirname(options.bundlePath)),
215
+ )
161
216
  let response: Response
162
217
  try {
163
218
  response = await fetch(`${apiOrigin}/v1/sims`, {
@@ -193,7 +248,7 @@ export async function createCloudSimulator(
193
248
  claimId: created.receipt.claim.id,
194
249
  token: created.token,
195
250
  })
196
- return { receipt: created.receipt, session }
251
+ return { receipt: created.receipt, session, artifact: artifact.receipt }
197
252
  }
198
253
 
199
254
  export function resolveCloudSessionForParsed(
@@ -219,40 +274,7 @@ function cloudCommand(command: {
219
274
  for (const [key, value] of Object.entries(command)) {
220
275
  if (key !== 'id' && key !== 'simId') clean[key] = value
221
276
  }
222
- if (command.type === 'query') {
223
- const query = command.query
224
- if (
225
- !isRecord(query) ||
226
- (query.kind !== 'tree' && query.kind !== 'find' && query.kind !== 'count')
227
- ) {
228
- throw new Error('RNX Cloud supports only structured tree, node, and count queries')
229
- }
230
- return clean
231
- }
232
- if (command.type === 'waitFor') {
233
- const options = command.waitForOptions
234
- const condition = isRecord(options) ? options.condition : null
235
- if (
236
- !isRecord(condition) ||
237
- (condition.type !== 'ready' &&
238
- condition.type !== 'present' &&
239
- condition.type !== 'absent')
240
- ) {
241
- throw new Error('RNX Cloud supports only ready and selector waits')
242
- }
243
- return clean
244
- }
245
- if (command.type === 'state') return clean
246
- if (command.type === 'capture' || command.type === 'captureRegions') return clean
247
- if (command.type === 'setAppearance' || command.type === 'openUrl') return clean
248
- if (
249
- command.type === 'screenshot' &&
250
- command.crop === undefined &&
251
- command.captureFilter === undefined &&
252
- (command.layers === undefined || command.layers === 'tenant')
253
- ) {
254
- return clean
255
- }
277
+ if (isRnxCloudCommandType(command.type)) return clean
256
278
  throw new Error(`RNX Cloud does not support bridge command type ${command.type}`)
257
279
  }
258
280
 
@@ -47,7 +47,7 @@ function supportsCloudRead(
47
47
  if (command === 'state') return firstVerb(positional) === null
48
48
  if (command === 'get') {
49
49
  const verb = firstVerb(positional)
50
- return verb === 'tree' || verb === 'node' || verb === 'count'
50
+ return verb === 'tree' || verb === 'node' || verb === 'count' || verb === 'memory'
51
51
  }
52
52
  if (command === 'wait') {
53
53
  const verb = firstVerb(positional)
@@ -77,6 +77,7 @@ function supportsCloudRead(
77
77
  ].includes(arg),
78
78
  )
79
79
  }
80
+ if (command === 'do' || command === 'reset') return true
80
81
  return false
81
82
  }
82
83
 
@@ -1095,8 +1095,16 @@ export async function inspectWaitReady(
1095
1095
  }
1096
1096
  }
1097
1097
  const probe = result.probe
1098
+ // a guest app that redboxes still paints: its ErrorBoundary is a real tree
1099
+ // with real nodes, so the engine's own readiness condition matches and every
1100
+ // caller reads exit 0 as "the app is up". an app that has errored is not
1101
+ // ready, so a guest error is disqualifying here rather than merely printed
1102
+ // on the failure path. `errors` (a console.error count) is deliberately not
1103
+ // used because apps can log errors benignly. `suppressedEntryError` remains
1104
+ // excluded because it never gates readiness by contract.
1105
+ const externalError = probe?.externalError ?? ''
1098
1106
  const status: WaitReadyStatus = {
1099
- ready: result.matched,
1107
+ ready: result.matched && !externalError,
1100
1108
  elapsedMs: result.elapsedMs,
1101
1109
  nodes: probe?.nodes ?? 0,
1102
1110
  targets: probe?.targets ?? 0,
@@ -1107,7 +1115,7 @@ export async function inspectWaitReady(
1107
1115
  loadingText: probe?.loadingText ?? '',
1108
1116
  externalReady: probe?.externalReady ?? null,
1109
1117
  externalStatus: probe?.externalStatus ?? '',
1110
- externalError: probe?.externalError ?? '',
1118
+ externalError,
1111
1119
  suppressedEntryError: probe?.suppressedEntryError ?? '',
1112
1120
  errors: probe?.errors ?? 0,
1113
1121
  bridgeError: result.error ?? '',
@@ -1835,6 +1843,29 @@ export interface MemoryReport {
1835
1843
  // picture and raster image — answers through its stats channel.
1836
1844
  // performance.memory is chrome-only, null on webkit.
1837
1845
  export async function inspectMemory(bridge: InspectBridge): Promise<MemoryReport> {
1846
+ if (bridge.plane === 'cloud') {
1847
+ const value: unknown = await bridge.send({ type: 'memory' })
1848
+ const rawWasmHeapBytes =
1849
+ typeof value === 'object' && value !== null
1850
+ ? Reflect.get(value, 'wasmHeapBytes')
1851
+ : null
1852
+ const wasmHeapBytes = typeof rawWasmHeapBytes === 'number' ? rawWasmHeapBytes : null
1853
+ return {
1854
+ tenant: {
1855
+ nodesRegistered: null,
1856
+ nodesDetached: null,
1857
+ detachedTypes: null,
1858
+ objects: null,
1859
+ activeNativeAnimations: null,
1860
+ imageLoader: null,
1861
+ workerHeap: null,
1862
+ wasmHeapBytes,
1863
+ },
1864
+ shell: null,
1865
+ compositor: null,
1866
+ hostHeap: null,
1867
+ }
1868
+ }
1838
1869
  const result = (await bridge.send({
1839
1870
  type: 'evaluate',
1840
1871
  code: `(async () => {
@@ -26,8 +26,15 @@ function isDeviceModel(value: string): value is DeviceModel {
26
26
  return value in devices
27
27
  }
28
28
 
29
- function cloudBundleFromArgs(args: string[]): string | null {
29
+ const CLOUD_USAGE =
30
+ 'usage: rnx ios --cloud <local bundle.js> [--assets <dir>] [--device <model>]'
31
+
32
+ function cloudInputFromArgs(args: string[]): {
33
+ bundlePath: string
34
+ assetsPath: string | null
35
+ } {
30
36
  const positional: string[] = []
37
+ let assetsPath: string | null = null
31
38
  for (let index = 0; index < args.length; index++) {
32
39
  const arg = args[index]
33
40
  if (arg === '--cloud') continue
@@ -35,6 +42,16 @@ function cloudBundleFromArgs(args: string[]): string | null {
35
42
  index++
36
43
  continue
37
44
  }
45
+ if (arg === '--assets') {
46
+ assetsPath = args[++index] ?? null
47
+ if (!assetsPath) throw new Error('rnx ios --cloud --assets requires a directory')
48
+ continue
49
+ }
50
+ if (arg.startsWith('--assets=')) {
51
+ assetsPath = arg.slice('--assets='.length) || null
52
+ if (!assetsPath) throw new Error('rnx ios --cloud --assets requires a directory')
53
+ continue
54
+ }
38
55
  if (
39
56
  arg === '--sim' ||
40
57
  arg === '--session' ||
@@ -50,10 +67,8 @@ function cloudBundleFromArgs(args: string[]): string | null {
50
67
  }
51
68
  positional.push(arg)
52
69
  }
53
- if (positional.length !== 1) {
54
- throw new Error('usage: rnx ios --cloud <local bundle.js> [--device <model>]')
55
- }
56
- return positional[0] ?? null
70
+ if (positional.length !== 1 || !positional[0]) throw new Error(CLOUD_USAGE)
71
+ return { bundlePath: positional[0], assetsPath }
57
72
  }
58
73
 
59
74
  export async function runPlatformCommand(
@@ -107,8 +122,7 @@ export async function runPlatformCommand(
107
122
  return 1
108
123
  }
109
124
  try {
110
- const bundlePath = cloudBundleFromArgs(args)
111
- if (!bundlePath) throw new Error('rnx ios --cloud requires a local bundle.js')
125
+ const input = cloudInputFromArgs(args)
112
126
  const {
113
127
  publishCloudSessionToShell,
114
128
  readCloudSession,
@@ -125,7 +139,8 @@ export async function runPlatformCommand(
125
139
  await import('../cloud-client')
126
140
  const { header } = authHeaderOrExit('ios --cloud')
127
141
  const created = await createCloudSimulator({
128
- bundlePath,
142
+ bundlePath: input.bundlePath,
143
+ assetsPath: input.assetsPath ?? undefined,
129
144
  device: selectedDevice,
130
145
  authorization: header,
131
146
  })
@@ -136,11 +151,21 @@ export async function runPlatformCommand(
136
151
  throw error
137
152
  }
138
153
  const receipt = created.receipt
154
+ const produced = created.artifact
139
155
  console.log(` cloud sim: ${receipt.simId}`)
140
156
  console.log(` artifact: ${receipt.artifact.id} (${receipt.artifact.bytes} bytes)`)
157
+ console.log(
158
+ ` source bundle: sha256:${produced.source.sha256} (${produced.source.bytes} bytes)`,
159
+ )
160
+ console.log(
161
+ ` normalized modules: ${produced.modules}; javascript ${produced.javascript.sourceBytes} -> ${produced.javascript.minifiedBytes} bytes`,
162
+ )
163
+ console.log(
164
+ ` embedded assets: ${produced.assets.descriptors} descriptors, ${produced.assets.variants} scales, ${produced.assets.bytes} bytes (${produced.assets.encodedBytes} encoded)`,
165
+ )
141
166
  console.log(` claim expires: ${receipt.claim.expiresAt}`)
142
167
  console.log(
143
- ' reads: describe, find, get tree|node|count, wait ready|selector, logs, state, screenshot',
168
+ ' commands: describe, find, get tree|node|count|memory, wait ready|selector, do, reset, logs, state, screenshot',
144
169
  )
145
170
  return 0
146
171
  } catch (error) {
@@ -0,0 +1,23 @@
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+
4
+ function attemptEventsPath() {
5
+ const enabled =
6
+ process.env.CONFORMANCE_PROOF === '1' || Boolean(process.env.CONFORMANCE_PROOF_DIR)
7
+ if (!enabled) return null
8
+ const root = path.resolve(
9
+ process.cwd(),
10
+ process.env.CONFORMANCE_PROOF_DIR || 'artifacts/conformance-proof/latest',
11
+ )
12
+ return path.join(root, 'events.jsonl')
13
+ }
14
+
15
+ function writeAttemptEvent(event) {
16
+ const output = attemptEventsPath()
17
+ if (output === null) return false
18
+ fs.mkdirSync(path.dirname(output), { recursive: true })
19
+ fs.appendFileSync(output, `${JSON.stringify(event)}\n`)
20
+ return true
21
+ }
22
+
23
+ module.exports = { attemptEventsPath, writeAttemptEvent }
@@ -21,8 +21,17 @@ export interface SootSimElement {
21
21
  typeText(text: string): Promise<void>
22
22
  replaceText(text: string): Promise<void>
23
23
  clearText(): Promise<void>
24
- scroll(pixels: number, direction: 'up' | 'down' | 'left' | 'right'): Promise<void>
25
- scrollTo(edge: 'top' | 'bottom' | 'left' | 'right'): Promise<void>
24
+ scroll(
25
+ pixels: number,
26
+ direction: 'up' | 'down' | 'left' | 'right',
27
+ startPositionX?: number,
28
+ startPositionY?: number,
29
+ ): Promise<void>
30
+ scrollTo(
31
+ edge: 'top' | 'bottom' | 'left' | 'right',
32
+ startPositionX?: number,
33
+ startPositionY?: number,
34
+ ): Promise<void>
26
35
  swipe(
27
36
  direction: 'up' | 'down' | 'left' | 'right',
28
37
  speed?: 'fast' | 'slow',
@@ -22,13 +22,15 @@ async function scrollMatcherElement(
22
22
  matcher: Matcher,
23
23
  pixels: number,
24
24
  direction: 'up' | 'down' | 'left' | 'right',
25
+ startPositionX?: number,
26
+ startPositionY?: number,
25
27
  ): Promise<number> {
26
28
  const node = await findNode(matcher)
27
29
  if (!node) {
28
30
  throw new Error(`scroll container not found: ${JSON.stringify(matcher)}`)
29
31
  }
30
32
  const before = scrollOffsetOf(node)
31
- await dragScrollNode(page, node, pixels, direction)
33
+ await dragScrollNode(page, node, pixels, direction, startPositionX, startPositionY)
32
34
  const after = scrollOffsetOf(await findNode(matcher))
33
35
  return Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
34
36
  }
@@ -500,8 +502,8 @@ class SootSimWaitForScrollAction {
500
502
  async scroll(
501
503
  pixels: number,
502
504
  direction: 'up' | 'down' | 'left' | 'right',
503
- _startPositionX?: number,
504
- _startPositionY?: number,
505
+ startPositionX?: number,
506
+ startPositionY?: number,
505
507
  ): Promise<void> {
506
508
  const page = this.getPage()
507
509
  let lastError: Error | null = null
@@ -523,6 +525,8 @@ class SootSimWaitForScrollAction {
523
525
  this.matcher,
524
526
  pixels,
525
527
  direction,
528
+ startPositionX,
529
+ startPositionY,
526
530
  )
527
531
  stalledSteps = travelled < 0.5 ? stalledSteps + 1 : 0
528
532
  }
package/detox/gestures.ts CHANGED
@@ -27,6 +27,17 @@ type WorkletCallbackStats = {
27
27
  shellSent: number
28
28
  }
29
29
 
30
+ export function resolveScrollStartPosition(
31
+ value: number | undefined,
32
+ label: string,
33
+ ): number {
34
+ const normalized = value === undefined || Number.isNaN(value) ? 0.5 : value
35
+ if (!Number.isFinite(normalized) || normalized < 0 || normalized > 1) {
36
+ throw new Error(`${label} must be between 0 and 1`)
37
+ }
38
+ return normalized
39
+ }
40
+
30
41
  type ShellGestureSeamDebug = {
31
42
  attached?: Array<{
32
43
  viewTag?: unknown
@@ -307,9 +318,19 @@ export async function dragScrollNode(
307
318
  },
308
319
  pixels: number,
309
320
  direction: 'up' | 'down' | 'left' | 'right',
321
+ startPositionX?: number,
322
+ startPositionY?: number,
310
323
  ): Promise<void> {
311
- const startSootSimX = node.absolutePosition.x + node.layout.width / 2
312
- const startSootSimY = node.absolutePosition.y + node.layout.height / 2
324
+ const normalizedStartX = resolveScrollStartPosition(
325
+ startPositionX,
326
+ 'scroll startPositionX',
327
+ )
328
+ const normalizedStartY = resolveScrollStartPosition(
329
+ startPositionY,
330
+ 'scroll startPositionY',
331
+ )
332
+ const startSootSimX = node.absolutePosition.x + node.layout.width * normalizedStartX
333
+ const startSootSimY = node.absolutePosition.y + node.layout.height * normalizedStartY
313
334
  let endSootSimX = startSootSimX
314
335
  let endSootSimY = startSootSimY
315
336
 
package/detox/index.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  dragScrollNode,
14
14
  readSootsimInteractiveViewport,
15
15
  readSootsimViewport,
16
+ resolveScrollStartPosition,
16
17
  sootsimToPage,
17
18
  waitForSootsimGestureHandler,
18
19
  } from './gestures'
@@ -23,6 +24,7 @@ import {
23
24
  type MotionChangeDefinition,
24
25
  type MotionChangeMeasurement,
25
26
  } from './motion-change.cjs'
27
+ import { describeSootsimBridgeFailure, navigateSootsimPage } from './navigation-lifecycle'
26
28
  import { REQUIRED_IDENTICAL_PROOF_FRAMES, framesIdentical } from './proof-frame.cjs'
27
29
  import type { SootSimElement } from './element-types'
28
30
  import type { Browser, BrowserContext, Page } from 'playwright'
@@ -45,7 +47,10 @@ let _context: BrowserContext | null = null
45
47
  let _page: Page | null = null
46
48
  let _synchronizationEnabled = false
47
49
  let _suspendedAppId: string | null = null
48
- const pageDiagnostics = new WeakMap<Page, string[]>()
50
+ let browserIdentity = 0
51
+ let contextIdentity = 0
52
+ let nextBrowserIdentity = 0
53
+ let nextContextIdentity = 0
49
54
 
50
55
  const STATUS_BAR_OVERRIDE_EVENT = 'sootsim:statusBarOverride'
51
56
 
@@ -67,59 +72,6 @@ function getPage(): Page {
67
72
  return _page
68
73
  }
69
74
 
70
- function recordPageDiagnostic(page: Page, message: string) {
71
- const entries = pageDiagnostics.get(page)
72
- if (!entries) return
73
- entries.push(message)
74
- if (entries.length > 40) entries.shift()
75
- }
76
-
77
- function attachPageDiagnostics(page: Page) {
78
- if (pageDiagnostics.has(page)) return
79
- pageDiagnostics.set(page, [])
80
- page.on('console', (message) => {
81
- recordPageDiagnostic(page, `[console:${message.type()}] ${message.text()}`)
82
- })
83
- page.on('pageerror', (error) => {
84
- recordPageDiagnostic(page, `[pageerror] ${error.message}`)
85
- })
86
- page.on('requestfailed', (request) => {
87
- const failure = request.failure()
88
- recordPageDiagnostic(
89
- page,
90
- `[requestfailed] ${request.method()} ${request.url()} ${failure?.errorText ?? ''}`,
91
- )
92
- })
93
- }
94
-
95
- async function describeSootsimBridgeFailure(page: Page, cause: unknown) {
96
- let pageState: unknown = null
97
- try {
98
- pageState = await page.evaluate(() => ({
99
- bodyText: document.body?.innerText?.slice(0, 500) ?? '',
100
- globals: Object.keys(window)
101
- .filter((key) => key.startsWith('__sootsim') || key === 'SootSim')
102
- .sort(),
103
- readyState: document.readyState,
104
- title: document.title,
105
- url: window.location.href,
106
- }))
107
- } catch (error) {
108
- pageState = {
109
- evaluateError: error instanceof Error ? error.message : String(error),
110
- }
111
- }
112
-
113
- return new Error(
114
- [
115
- `timed out waiting for sootsim test bridge`,
116
- `cause: ${cause instanceof Error ? cause.message : String(cause)}`,
117
- `page: ${JSON.stringify(pageState)}`,
118
- `recent page diagnostics:\n${(pageDiagnostics.get(page) ?? []).join('\n') || '(none)'}`,
119
- ].join('\n'),
120
- )
121
- }
122
-
123
75
  async function waitForSootsimTree(page: Page, timeout = 30000): Promise<void> {
124
76
  try {
125
77
  await page.waitForFunction(() => !!window.__sootsimTest?.waitForTree, {
@@ -324,6 +276,7 @@ async function closeContext() {
324
276
  const context = _context
325
277
  _context = null
326
278
  _page = null
279
+ contextIdentity = 0
327
280
  _synchronizationEnabled = false
328
281
  if (context) await context.close()
329
282
  }
@@ -334,6 +287,7 @@ async function closeBrowser() {
334
287
  const reapBrowserNow = _reapBrowserNow
335
288
  _browser = null
336
289
  _reapBrowserNow = null
290
+ browserIdentity = 0
337
291
  await closeContext()
338
292
  try {
339
293
  await browser.close()
@@ -943,15 +897,24 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
943
897
  }
944
898
  },
945
899
 
946
- async scroll(pixels: number, direction: 'up' | 'down' | 'left' | 'right') {
900
+ async scroll(
901
+ pixels: number,
902
+ direction: 'up' | 'down' | 'left' | 'right',
903
+ startPositionX?: number,
904
+ startPositionY?: number,
905
+ ) {
947
906
  const node = await findNodeByMatcher(matcher)
948
907
  if (!node)
949
908
  throw new Error(`element not found for scroll: ${JSON.stringify(matcher)}`)
950
909
  const page = getPage()
951
- await dragScrollNode(page, node, pixels, direction)
910
+ await dragScrollNode(page, node, pixels, direction, startPositionX, startPositionY)
952
911
  },
953
912
 
954
- async scrollTo(edge: 'top' | 'bottom' | 'left' | 'right') {
913
+ async scrollTo(
914
+ edge: 'top' | 'bottom' | 'left' | 'right',
915
+ startPositionX?: number,
916
+ startPositionY?: number,
917
+ ) {
955
918
  const node = await findNodeByMatcher(matcher)
956
919
  if (!node)
957
920
  throw new Error(`element not found for scrollTo: ${JSON.stringify(matcher)}`)
@@ -968,8 +931,16 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
968
931
  )
969
932
  }
970
933
 
971
- const hitX = node.absolutePosition.x + node.layout.width / 2
972
- const hitY = node.absolutePosition.y + node.layout.height / 2
934
+ const normalizedStartX = resolveScrollStartPosition(
935
+ startPositionX,
936
+ 'scrollTo startPositionX',
937
+ )
938
+ const normalizedStartY = resolveScrollStartPosition(
939
+ startPositionY,
940
+ 'scrollTo startPositionY',
941
+ )
942
+ const hitX = node.absolutePosition.x + node.layout.width * normalizedStartX
943
+ const hitY = node.absolutePosition.y + node.layout.height * normalizedStartY
973
944
  const page = getPage()
974
945
  const result = await page.evaluate(
975
946
  async ({ edge, hitX, hitY, targetId }) => {
@@ -1348,6 +1319,7 @@ export const device = {
1348
1319
  )
1349
1320
  _browser = launched.browser
1350
1321
  _reapBrowserNow = launched.reapNow
1322
+ browserIdentity = ++nextBrowserIdentity
1351
1323
  }
1352
1324
 
1353
1325
  if (!_page || opts?.newInstance || opts?.delete || opts?.recordVideoDir) {
@@ -1365,12 +1337,16 @@ export const device = {
1365
1337
  }
1366
1338
  _context = await _browser.newContext(contextOpts)
1367
1339
  _page = await _context.newPage()
1368
- attachPageDiagnostics(_page)
1340
+ contextIdentity = ++nextContextIdentity
1369
1341
  }
1370
1342
 
1371
1343
  const url = opts?.url || BASE_URL
1372
1344
  device._currentUrl = url
1373
- await _page!.goto(url, { waitUntil: 'load', timeout: 30000 })
1345
+ await navigateSootsimPage(_page!, url, {
1346
+ browserId: browserIdentity,
1347
+ contextId: contextIdentity,
1348
+ operation: 'launchApp',
1349
+ })
1374
1350
 
1375
1351
  await waitForSootsimTree(_page!, 30000)
1376
1352
  await waitForSootsimSurfaceMetrics(_page!, 30000)
@@ -1406,7 +1382,11 @@ export const device = {
1406
1382
 
1407
1383
  async openURL(url: { url: string; sourceApp?: string }) {
1408
1384
  const page = getPage()
1409
- await page.goto(url.url, { waitUntil: 'load', timeout: 30000 })
1385
+ await navigateSootsimPage(page, url.url, {
1386
+ browserId: browserIdentity,
1387
+ contextId: contextIdentity,
1388
+ operation: 'openURL',
1389
+ })
1410
1390
  await waitForSootsimTree(page, 10000)
1411
1391
  await waitForSootsimSurfaceMetrics(page, 10000)
1412
1392
  await waitForSootsimShellReady(page, 10000)