rnxsim 0.1.417 → 0.1.419

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 (66) hide show
  1. package/cli/cloud-client.ts +104 -32
  2. package/cli/cloud-dispatch.ts +38 -4
  3. package/cli/commands/inspect/actions.ts +313 -385
  4. package/cli/commands/inspect/core.ts +27 -12
  5. package/cli/commands/inspect/resolve-target.ts +2 -3
  6. package/cli/commands/inspect/settle.ts +10 -0
  7. package/cli/commands/inspect/wait-ready.ts +2 -0
  8. package/cli/commands/inspect.ts +31 -48
  9. package/cli/commands/platform.ts +41 -10
  10. package/cli/outbound-endpoints.ts +1 -1
  11. package/cli/self-invocation.ts +2 -2
  12. package/cli/shell-init.ts +1 -1
  13. package/dist-lib/agent-daemon-client.cjs +1 -1
  14. package/dist-lib/agent-events.cjs +1 -1
  15. package/dist-lib/agent-identity.cjs +1 -1
  16. package/dist-lib/agent-sessions.cjs +1 -1
  17. package/dist-lib/attached-projects.cjs +1 -1
  18. package/dist-lib/auth/shared-session.cjs +1 -1
  19. package/dist-lib/backend-origin.cjs +1 -1
  20. package/dist-lib/beta.cjs +1 -1
  21. package/dist-lib/beta.mjs +1 -1
  22. package/dist-lib/bridge-constants.cjs +1 -1
  23. package/dist-lib/bridge-contract-input.cjs +1 -1
  24. package/dist-lib/bridge-contract-input.mjs +1 -1
  25. package/dist-lib/bridge-contract.cjs +7 -1
  26. package/dist-lib/bridge-contract.mjs +5 -1
  27. package/dist-lib/capture-contract.cjs +1 -1
  28. package/dist-lib/capture-contract.mjs +1 -1
  29. package/dist-lib/cli-constants.cjs +1 -1
  30. package/dist-lib/cloud-contract.cjs +1 -1
  31. package/dist-lib/cloud-contract.mjs +1 -1
  32. package/dist-lib/config.cjs +1 -1
  33. package/dist-lib/detox/index.cjs +1 -1
  34. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  35. package/dist-lib/home-paths.cjs +1 -1
  36. package/dist-lib/host/bridge-host.cjs +1 -1
  37. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  38. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  39. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  40. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  41. package/dist-lib/host/websocket-proxy.cjs +1 -1
  42. package/dist-lib/index.cjs +631 -749
  43. package/dist-lib/jump-to-source-babel.cjs +1 -1
  44. package/dist-lib/menu.cjs +1 -1
  45. package/dist-lib/menu.mjs +1 -1
  46. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  47. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  48. package/dist-lib/metro-production-bundle.cjs +1 -1
  49. package/dist-lib/metro-production-bundle.mjs +1 -1
  50. package/dist-lib/metro.cjs +1 -1
  51. package/dist-lib/profiles.cjs +1 -1
  52. package/dist-lib/public-brand.cjs +1 -1
  53. package/dist-lib/react-native-host-modules.cjs +1 -1
  54. package/dist-lib/react-native-host-modules.mjs +1 -1
  55. package/dist-lib/render-mode.cjs +1 -1
  56. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  57. package/dist-lib/sdk.cjs +1209 -1342
  58. package/dist-lib/sdk.mjs +1209 -1342
  59. package/dist-lib/skills.cjs +63 -2
  60. package/dist-lib/vite.cjs +1 -1
  61. package/package.json +1 -1
  62. package/src/bridge-contract.ts +3 -0
  63. package/src/host/bridge-host.ts +1 -1
  64. package/src/host/plane-host.ts +1 -1
  65. package/src/native-dev-bundle-url.ts +1 -1
  66. package/src/vite-plugin.ts +1 -1
@@ -1,5 +1,7 @@
1
+ import { inspectTree, type InspectBridge } from './core'
2
+ import { resolveTargetCoords } from './resolve-target'
1
3
  import { waitForSootsimIdle } from './settling'
2
- import type { InspectBridge } from './core'
4
+ import type { SimSemanticNode } from '../../../src/bridge-contract'
3
5
 
4
6
  export type TapFailure = 'not-found' | 'missed' | 'special'
5
7
 
@@ -37,18 +39,99 @@ export interface TapTimingOptions {
37
39
  retryWaitMs?: number
38
40
  }
39
41
 
40
- // the device screen a finger can actually reach, evaluated in the page. the
41
- // compositor keeps one canvas per surface and parks the inactive ones at zero
42
- // size, so measuring `canvas[data-surface-id]` reads whichever element happens
43
- // to come first in the DOM `home`, sized 0x0 whenever an app is foreground —
44
- // which left the offscreen guard below silently disabled inside every app. the
45
- // engine publishes the real device spec on this snapshot instead.
46
- const SCREEN_EVAL = `(() => {
47
- const spec = window.SootSim?.state?.engineSnapshot?.windowState?.deviceSpec
48
- return spec && spec.width > 0 && spec.height > 0
49
- ? { width: spec.width, height: spec.height }
50
- : null
51
- })()`
42
+ type ScreenSize = { width: number; height: number }
43
+
44
+ function isScreenSize(value: unknown): value is ScreenSize {
45
+ if (value === null || typeof value !== 'object') return false
46
+ const width = Reflect.get(value, 'width')
47
+ const height = Reflect.get(value, 'height')
48
+ return (
49
+ typeof width === 'number' &&
50
+ Number.isFinite(width) &&
51
+ width > 0 &&
52
+ typeof height === 'number' &&
53
+ Number.isFinite(height) &&
54
+ height > 0
55
+ )
56
+ }
57
+
58
+ function viewportFromCapture(value: unknown): unknown {
59
+ if (value === null || typeof value !== 'object') return undefined
60
+ const tree = Reflect.get(value, 'tree')
61
+ return tree !== null && typeof tree === 'object'
62
+ ? Reflect.get(tree, 'viewport')
63
+ : undefined
64
+ }
65
+
66
+ // the semantic tree has no covering root, so its first node is often a 64x64
67
+ // icon. capture.tree.viewport is the device layout size on local and cloud.
68
+ async function readScreen(bridge: InspectBridge): Promise<ScreenSize | null> {
69
+ try {
70
+ const viewport = viewportFromCapture(
71
+ await bridge.send({ type: 'capture', includeVisual: false }),
72
+ )
73
+ return isScreenSize(viewport)
74
+ ? { width: viewport.width, height: viewport.height }
75
+ : null
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ function offscreenPayload(
82
+ cx: number,
83
+ cy: number,
84
+ screen: ScreenSize | null,
85
+ ): { offscreen: true; screen: ScreenSize } | Record<string, never> {
86
+ if (!screen || (cx >= 0 && cy >= 0 && cx <= screen.width && cy <= screen.height)) {
87
+ return {}
88
+ }
89
+ return { offscreen: true, screen }
90
+ }
91
+
92
+ function geometryContains(geometry: SimSemanticNode['geometry'], x: number, y: number) {
93
+ return (
94
+ x >= geometry.x &&
95
+ y >= geometry.y &&
96
+ x <= geometry.x + geometry.width &&
97
+ y <= geometry.y + geometry.height
98
+ )
99
+ }
100
+
101
+ function hitTestSemanticTree(
102
+ nodes: readonly SimSemanticNode[],
103
+ x: number,
104
+ y: number,
105
+ ancestors: SimSemanticNode[] = [],
106
+ ): { node: SimSemanticNode; ancestors: SimSemanticNode[] } | null {
107
+ for (let index = nodes.length - 1; index >= 0; index--) {
108
+ const node = nodes[index]
109
+ if (!node || !geometryContains(node.geometry, x, y)) continue
110
+ const childHit = node.children?.length
111
+ ? hitTestSemanticTree(node.children, x, y, [...ancestors, node])
112
+ : null
113
+ return childHit ?? { node, ancestors }
114
+ }
115
+ return null
116
+ }
117
+
118
+ function nodeMatchesRequested(node: SimSemanticNode, requested: TapTargetSummary) {
119
+ if (typeof requested.nodeId === 'number' && node.nodeId === requested.nodeId) {
120
+ return true
121
+ }
122
+ if (requested.testID && node.testID === requested.testID) return true
123
+ if (requested.id && node.testID === requested.id) return true
124
+ return false
125
+ }
126
+
127
+ function coveringNodeSummary(node: SimSemanticNode) {
128
+ return {
129
+ nodeId: node.nodeId,
130
+ testID: node.testID ?? null,
131
+ text: node.text ?? node.label ?? null,
132
+ type: node.type,
133
+ }
134
+ }
52
135
 
53
136
  const DEFAULT_AGENT_TIMING: Required<TapTimingOptions> = {
54
137
  initialWaitMs: 3000,
@@ -71,6 +154,7 @@ function tapTiming(agent: boolean, opts: TapTimingOptions = {}) {
71
154
 
72
155
  export function isTapSuccess(result: any): boolean {
73
156
  if (!result || result.hit === false || result.ok === false) return false
157
+ if (result.handled === false) return false
74
158
  if (result.requestedTargetMatched === false) return false
75
159
  if (
76
160
  result.pointerTapHandled === false &&
@@ -118,22 +202,30 @@ export async function tapResolvedTarget(
118
202
  let lastPayload: any = null
119
203
  let lastResult: any = null
120
204
 
121
- try {
122
- await waitForSootsimIdle({
123
- bridge,
124
- maxMs: timing.initialWaitMs,
125
- pollMs: 32,
126
- stablePolls: 2,
127
- })
128
- } catch {
129
- // best-effort readiness gate; the retry loop decides success.
205
+ const canWaitForIdle = bridge.plane !== 'cloud' && timing.initialWaitMs > 0
206
+ if (canWaitForIdle) {
207
+ try {
208
+ await waitForSootsimIdle({
209
+ bridge,
210
+ maxMs: timing.initialWaitMs,
211
+ pollMs: 32,
212
+ stablePolls: 2,
213
+ })
214
+ } catch {
215
+ // best-effort readiness gate; the retry loop decides success.
216
+ }
130
217
  }
131
218
 
132
219
  const deadline = Date.now() + timing.deadlineMs
133
220
  let lastOffscreenAt: string | null = null
221
+ const screen = await readScreen(bridge)
134
222
  while (Date.now() <= deadline || attempts === 0) {
135
223
  attempts++
136
- const payload = await args.resolve()
224
+ const resolved = await args.resolve()
225
+ const payload =
226
+ resolved && typeof resolved.cx === 'number' && typeof resolved.cy === 'number'
227
+ ? { ...resolved, ...offscreenPayload(resolved.cx, resolved.cy, screen) }
228
+ : resolved
137
229
  lastPayload = payload
138
230
 
139
231
  if (
@@ -170,122 +262,31 @@ export async function tapResolvedTarget(
170
262
  } else {
171
263
  lastOffscreenAt = null
172
264
  const requestedTarget = tapTargetFromPayload(payload, args.textFallback)
173
- const verification = await bridge.send({
174
- type: 'evaluate',
175
- code: `(async () => {
176
- const t = window.__sootsimTest
177
- if (
178
- !t ||
179
- typeof t.inspectAt !== 'function' ||
180
- typeof t.resolveTapTarget !== 'function'
181
- ) {
182
- return { error: 'tap-target-inspection-unavailable' }
183
- }
184
- const requestedTarget = ${JSON.stringify(requestedTarget)}
185
- const inspected = await t.inspectAt(${payload.cx}, ${payload.cy})
186
- const resolved =
187
- inspected && typeof inspected.nodeId === 'number'
188
- ? await t.resolveTapTarget(inspected.nodeId)
189
- : null
190
- const node = (resolved && resolved.target) || inspected
191
- const coordinateTarget = node
192
- ? {
193
- nodeId: node.nodeId ?? null,
194
- id: node.id ?? null,
195
- testID: node.testID ?? null,
196
- text: node.text ?? node.accessibilityLabel ?? null,
197
- type: node.type ?? null,
198
- }
199
- : null
200
- const coordinateAncestors = Array.isArray(inspected?.ancestors)
201
- ? inspected.ancestors
202
- : []
203
- const ancestorMatchesRequested = coordinateAncestors.some(
204
- (ancestor) =>
205
- (typeof requestedTarget.nodeId === 'number' &&
206
- ancestor?.nodeId === requestedTarget.nodeId) ||
207
- (requestedTarget.testID &&
208
- ancestor?.testID === requestedTarget.testID) ||
209
- (requestedTarget.id && ancestor?.id === requestedTarget.id),
210
- )
211
- const requestedTargetMatched =
212
- coordinateTarget === null
213
- ? false
214
- : typeof requestedTarget.nodeId === 'number' &&
215
- typeof coordinateTarget.nodeId === 'number'
216
- ? requestedTarget.nodeId === coordinateTarget.nodeId ||
217
- ancestorMatchesRequested
218
- : requestedTarget.testID && coordinateTarget.testID
219
- ? requestedTarget.testID === coordinateTarget.testID
220
- || ancestorMatchesRequested
221
- : requestedTarget.id && coordinateTarget.id
222
- ? requestedTarget.id === coordinateTarget.id
223
- || ancestorMatchesRequested
224
- : ancestorMatchesRequested
225
- if (!requestedTargetMatched) {
226
- return {
227
- requestedTargetMatched,
228
- requestedTarget,
229
- coordinateTarget,
230
- }
231
- }
232
- if (typeof t.activatePressAt !== 'function') {
233
- return { error: 'tap-target-activation-unavailable' }
234
- }
235
- const activation = await t.activatePressAt(
236
- ${payload.cx},
237
- ${payload.cy},
238
- requestedTarget.nodeId,
239
- )
240
- if (activation?.ok) {
241
- window.dispatchEvent(
242
- new CustomEvent('sootsim:agentAction', {
243
- detail: {
244
- type: 'tap',
245
- x: ${payload.cx},
246
- y: ${payload.cy},
247
- target: requestedTarget,
248
- },
249
- }),
250
- )
251
- }
252
- return {
253
- activation,
254
- requestedTargetMatched,
255
- requestedTarget,
256
- coordinateTarget,
257
- }
258
- })()`,
259
- })
260
- if (verification?.error) {
261
- return {
262
- payload,
263
- result: {
264
- hit: false,
265
- reason: verification.error,
266
- requestedTarget,
267
- },
268
- attempts,
269
- failure: 'special',
270
- }
271
- }
272
- if (verification?.requestedTargetMatched === false) {
273
- return {
274
- payload,
275
- result: {
265
+ const { tree } = await inspectTree(bridge, 50)
266
+ if (Array.isArray(tree) && tree.length > 0) {
267
+ const hit = hitTestSemanticTree(tree, payload.cx, payload.cy)
268
+ const matched =
269
+ hit !== null &&
270
+ (nodeMatchesRequested(hit.node, requestedTarget) ||
271
+ hit.ancestors.some((ancestor) =>
272
+ nodeMatchesRequested(ancestor, requestedTarget),
273
+ ))
274
+ if (hit && !matched) {
275
+ lastResult = {
276
276
  hit: false,
277
277
  reason: 'target-covered',
278
- ...verification,
279
- },
280
- attempts,
281
- failure: 'missed',
278
+ requestedTarget,
279
+ coordinateTarget: coveringNodeSummary(hit.node),
280
+ }
281
+ return { payload, result: lastResult, attempts, failure: 'missed' }
282
282
  }
283
283
  }
284
- const dispatched = verification?.activation?.tap ?? verification?.activation
285
- const result =
286
- dispatched && typeof dispatched === 'object'
287
- ? { ...dispatched, ...verification?.activation, ...verification }
288
- : { hit: !!dispatched, ...verification }
284
+ const result = await tapCoordinates(
285
+ bridge,
286
+ payload.cx,
287
+ payload.cy,
288
+ requestedTarget,
289
+ )
289
290
  lastResult = result
290
291
  if (isTapSuccess(result)) return { payload, result, attempts }
291
292
  }
@@ -293,6 +294,7 @@ export async function tapResolvedTarget(
293
294
 
294
295
  const remaining = deadline - Date.now()
295
296
  if (remaining <= 0) break
297
+ if (bridge.plane === 'cloud' || timing.retryWaitMs <= 0) break
296
298
  try {
297
299
  await waitForSootsimIdle({
298
300
  bridge,
@@ -327,66 +329,81 @@ export async function tapCoordinates(
327
329
  })
328
330
  }
329
331
 
332
+ function payloadFromResolved(
333
+ resolved: {
334
+ x: number
335
+ y: number
336
+ testID?: string | null
337
+ text?: string | null
338
+ type?: string | null
339
+ },
340
+ match: {
341
+ nodeId?: number | null
342
+ testID?: string | null
343
+ text?: string | null
344
+ type?: string | null
345
+ },
346
+ extra: Record<string, unknown> = {},
347
+ ) {
348
+ return {
349
+ cx: resolved.x,
350
+ cy: resolved.y,
351
+ match: {
352
+ nodeId: match.nodeId ?? null,
353
+ id: match.testID ?? null,
354
+ testID: match.testID ?? null,
355
+ text: match.text ?? null,
356
+ type: match.type ?? null,
357
+ },
358
+ target: {
359
+ nodeId: match.nodeId ?? null,
360
+ id: resolved.testID ?? match.testID ?? null,
361
+ testID: resolved.testID ?? match.testID ?? null,
362
+ text: resolved.text ?? match.text ?? null,
363
+ type: resolved.type ?? match.type ?? null,
364
+ },
365
+ strategy: 'matched-node',
366
+ ...extra,
367
+ }
368
+ }
369
+
370
+ interface TextCandidate {
371
+ node: SimSemanticNode
372
+ ancestorTestIDs: string[]
373
+ }
374
+
375
+ function collectTextCandidates(
376
+ nodes: readonly SimSemanticNode[],
377
+ ancestors: string[] = [],
378
+ ): TextCandidate[] {
379
+ const out: TextCandidate[] = []
380
+ for (const node of nodes) {
381
+ out.push({ node, ancestorTestIDs: ancestors })
382
+ if (node.children?.length) {
383
+ const next = node.testID ? [...ancestors, node.testID] : ancestors
384
+ out.push(...collectTextCandidates(node.children, next))
385
+ }
386
+ }
387
+ return out
388
+ }
389
+
390
+ function nodeText(node: SimSemanticNode): string {
391
+ return node.text ?? node.label ?? ''
392
+ }
393
+
330
394
  export async function tapById(
331
395
  bridge: InspectBridge,
332
396
  query: string,
333
397
  opts: { agent?: boolean; timing?: TapTimingOptions } = {},
334
398
  ): Promise<TapOutcome> {
335
- const arg = JSON.stringify(query)
336
399
  return tapResolvedTarget(bridge, {
337
400
  agent: opts.agent,
338
401
  timing: opts.timing,
339
- resolve: () =>
340
- bridge.send({
341
- type: 'evaluate',
342
- code: `(async () => {
343
- const t = window.__sootsimTest
344
- if (!t) return null
345
- const n = (await t.findByTestId(${arg})) || (await t.findById(${arg}))
346
- if (!n || !n.absolutePosition || !n.layout) return { cx: null }
347
- const resolved =
348
- typeof n.nodeId === 'number' && typeof t.resolveTapTarget === 'function'
349
- ? await t.resolveTapTarget(n.nodeId)
350
- : null
351
- const target = (resolved && resolved.target) || n
352
- const cx =
353
- resolved && typeof resolved.cx === 'number'
354
- ? resolved.cx
355
- : n.absolutePosition.x + (n.layout.width || 0) / 2
356
- const cy =
357
- resolved && typeof resolved.cy === 'number'
358
- ? resolved.cy
359
- : n.absolutePosition.y + (n.layout.height || 0) / 2
360
- const scr = ${SCREEN_EVAL}
361
- const offscreen =
362
- !!scr && (cx < 0 || cy < 0 || cx > scr.width || cy > scr.height)
363
- return {
364
- cx,
365
- cy,
366
- ...(offscreen ? { offscreen: true, screen: scr } : {}),
367
- match: {
368
- nodeId: n.nodeId ?? null,
369
- id: n.id,
370
- testID: n.testID,
371
- text: n.text ?? n.accessibilityLabel ?? null,
372
- type: n.type,
373
- },
374
- target: {
375
- nodeId: target.nodeId ?? null,
376
- id: target.id,
377
- testID: target.testID,
378
- text:
379
- target.text ??
380
- target.accessibilityLabel ??
381
- n.text ??
382
- n.accessibilityLabel ??
383
- null,
384
- type: target.type,
385
- },
386
- strategy: (resolved && resolved.strategy) || 'matched-node',
387
- }
388
- })()`,
389
- }),
402
+ resolve: async () => {
403
+ const resolved = await resolveTargetCoords(bridge, { mode: 'testid', value: query })
404
+ if (!resolved) return { cx: null }
405
+ return payloadFromResolved(resolved, resolved)
406
+ },
390
407
  })
391
408
  }
392
409
 
@@ -396,153 +413,96 @@ export async function tapByText(
396
413
  options: TapTextOptions = {},
397
414
  opts: { agent?: boolean; timing?: TapTimingOptions } = {},
398
415
  ): Promise<TapOutcome> {
399
- const filterArg = JSON.stringify({
400
- query,
401
- exact: !!options.exact,
402
- role: options.role ?? null,
403
- within: options.within ?? null,
404
- minX: options.minX ?? null,
405
- maxX: options.maxX ?? null,
406
- minY: options.minY ?? null,
407
- maxY: options.maxY ?? null,
408
- near: options.near ?? null,
409
- nth: options.nth ?? null,
410
- first: !!options.first,
411
- })
412
-
413
416
  return tapResolvedTarget(bridge, {
414
417
  agent: opts.agent,
415
418
  timing: opts.timing,
416
419
  textFallback: query,
417
- resolve: () =>
418
- bridge.send({
419
- type: 'evaluate',
420
- code: `(async () => {
421
- const t = window.__sootsimTest
422
- if (!t) return { error: 'bridge-not-ready' }
423
- const F = ${filterArg}
424
-
425
- const res = await t.queryTextCandidates({
426
- query: F.query,
427
- exact: !!F.exact,
428
- ...(F.role ? { role: F.role } : {}),
429
- })
430
-
431
- let candidates = res.candidates || []
432
-
433
- candidates = candidates.filter((c) => {
434
- const ax = c.info.absolutePosition && c.info.absolutePosition.x
435
- const ay = c.info.absolutePosition && c.info.absolutePosition.y
436
- if (F.minX !== null && !(ax >= F.minX)) return false
437
- if (F.maxX !== null && !(ax <= F.maxX)) return false
438
- if (F.minY !== null && !(ay >= F.minY)) return false
439
- if (F.maxY !== null && !(ay <= F.maxY)) return false
440
- if (F.within && !c.ancestorTestIDs.includes(F.within)) return false
441
- return true
442
- })
420
+ resolve: async () => {
421
+ const { tree } = await inspectTree(bridge, 50)
422
+ if (!Array.isArray(tree)) return { matched: 0, total: 0 }
423
+ const needle = options.exact ? query : query.toLowerCase()
424
+ let candidates = collectTextCandidates(tree).filter(({ node, ancestorTestIDs }) => {
425
+ const text = nodeText(node)
426
+ if (!text) return false
427
+ if (options.exact ? text !== needle : !text.toLowerCase().includes(needle)) {
428
+ return false
429
+ }
430
+ if (options.role && node.role !== options.role) return false
431
+ if (options.within && !ancestorTestIDs.includes(options.within)) return false
432
+ const ax = node.geometry.x
433
+ const ay = node.geometry.y
434
+ if (options.minX != null && !(ax >= options.minX)) return false
435
+ if (options.maxX != null && !(ax <= options.maxX)) return false
436
+ if (options.minY != null && !(ay >= options.minY)) return false
437
+ if (options.maxY != null && !(ay <= options.maxY)) return false
438
+ return true
439
+ })
443
440
 
444
- if (F.near) {
445
- candidates.sort((a, b) => {
446
- const ax = (a.info.absolutePosition && a.info.absolutePosition.x) || 0
447
- const ay = (a.info.absolutePosition && a.info.absolutePosition.y) || 0
448
- const bx = (b.info.absolutePosition && b.info.absolutePosition.x) || 0
449
- const by = (b.info.absolutePosition && b.info.absolutePosition.y) || 0
450
- return (
451
- Math.hypot(ax - F.near.x, ay - F.near.y) -
452
- Math.hypot(bx - F.near.x, by - F.near.y)
453
- )
454
- })
455
- } else {
456
- candidates.sort((a, b) => {
457
- const ay = (a.info.absolutePosition && a.info.absolutePosition.y) || 0
458
- const by = (b.info.absolutePosition && b.info.absolutePosition.y) || 0
459
- if (Math.abs(ay - by) > 2) return ay - by
460
- const ax = (a.info.absolutePosition && a.info.absolutePosition.x) || 0
461
- const bx = (b.info.absolutePosition && b.info.absolutePosition.x) || 0
462
- return ax - bx
463
- })
441
+ if (options.near) {
442
+ const { x, y } = options.near
443
+ candidates.sort(
444
+ (a, b) =>
445
+ Math.hypot(a.node.geometry.x - x, a.node.geometry.y - y) -
446
+ Math.hypot(b.node.geometry.x - x, b.node.geometry.y - y),
447
+ )
448
+ } else {
449
+ candidates.sort((a, b) => {
450
+ if (Math.abs(a.node.geometry.y - b.node.geometry.y) > 2) {
451
+ return a.node.geometry.y - b.node.geometry.y
464
452
  }
453
+ return a.node.geometry.x - b.node.geometry.x
454
+ })
455
+ }
465
456
 
466
- const total = candidates.length
467
- if (total === 0) return { matched: 0, total: 0 }
457
+ const total = candidates.length
458
+ if (total === 0) return { matched: 0, total: 0 }
468
459
 
469
- let idx = 0
470
- if (F.nth !== null) {
471
- idx = F.nth < 0 ? total + F.nth : F.nth
472
- if (idx < 0 || idx >= total) {
473
- return { matched: 0, total, nthOutOfRange: true, nth: F.nth }
474
- }
475
- } else if (total > 1 && !F.first && !F.near) {
476
- return {
477
- ambiguous: true,
478
- total,
479
- candidates: candidates.slice(0, 10).map((c, i) => ({
480
- idx: i,
481
- nodeId: c.info.nodeId,
482
- type: c.info.type,
483
- testID: c.info.testID,
484
- text: (c.info.text || '').slice(0, 60),
485
- abs: c.info.absolutePosition,
486
- layout: c.info.layout
487
- ? {
488
- width: Math.round(c.info.layout.width || 0),
489
- height: Math.round(c.info.layout.height || 0),
490
- }
491
- : null,
492
- ancestorTestIDs: (c.ancestorTestIDs || []).slice(0, 5),
493
- })),
494
- }
495
- }
496
-
497
- const picked = candidates[idx]
498
- const n = picked.info
499
- if (!n.absolutePosition || !n.layout) return { matched: 0, total }
500
-
501
- const resolved =
502
- typeof n.nodeId === 'number' &&
503
- typeof t.resolveTapTarget === 'function'
504
- ? await t.resolveTapTarget(n.nodeId)
505
- : null
506
- const target = (resolved && resolved.target) || n
507
- const cx =
508
- resolved && typeof resolved.cx === 'number'
509
- ? resolved.cx
510
- : n.absolutePosition.x + (n.layout.width || 0) / 2
511
- const cy =
512
- resolved && typeof resolved.cy === 'number'
513
- ? resolved.cy
514
- : n.absolutePosition.y + (n.layout.height || 0) / 2
515
- const scr = ${SCREEN_EVAL}
516
- const offscreen =
517
- !!scr && (cx < 0 || cy < 0 || cx > scr.width || cy > scr.height)
518
- return {
519
- cx,
520
- cy,
521
- ...(offscreen ? { offscreen: true, screen: scr } : {}),
522
- match: {
523
- nodeId: n.nodeId ?? null,
524
- id: n.id,
525
- testID: n.testID,
526
- type: n.type,
527
- },
528
- target: {
529
- nodeId: target.nodeId ?? null,
530
- id: target.id,
531
- testID: target.testID,
532
- text:
533
- target.text ??
534
- target.accessibilityLabel ??
535
- n.text ??
536
- n.accessibilityLabel ??
537
- null,
538
- type: target.type,
460
+ let idx = 0
461
+ if (options.nth != null) {
462
+ idx = options.nth < 0 ? total + options.nth : options.nth
463
+ if (idx < 0 || idx >= total) {
464
+ return { matched: 0, total, nthOutOfRange: true, nth: options.nth }
465
+ }
466
+ } else if (total > 1 && !options.first && !options.near) {
467
+ return {
468
+ ambiguous: true,
469
+ total,
470
+ candidates: candidates.slice(0, 10).map((candidate, index) => ({
471
+ idx: index,
472
+ nodeId: candidate.node.nodeId,
473
+ type: candidate.node.type,
474
+ testID: candidate.node.testID ?? null,
475
+ text: nodeText(candidate.node).slice(0, 60),
476
+ abs: { x: candidate.node.geometry.x, y: candidate.node.geometry.y },
477
+ layout: {
478
+ width: Math.round(candidate.node.geometry.width),
479
+ height: Math.round(candidate.node.geometry.height),
539
480
  },
540
- strategy: (resolved && resolved.strategy) || 'matched-node',
541
- total,
542
- idx,
543
- }
544
- })()`,
545
- }),
481
+ ancestorTestIDs: candidate.ancestorTestIDs.slice(0, 5),
482
+ })),
483
+ }
484
+ }
485
+
486
+ const picked = candidates[idx]
487
+ if (!picked) return { matched: 0, total }
488
+ const node = picked.node
489
+ let resolved = node.testID
490
+ ? await resolveTargetCoords(bridge, { mode: 'testid', value: node.testID })
491
+ : await resolveTargetCoords(bridge, {
492
+ mode: 'text',
493
+ value: nodeText(node) || query,
494
+ })
495
+ if (!resolved) {
496
+ resolved = {
497
+ x: node.geometry.x + node.geometry.width / 2,
498
+ y: node.geometry.y + node.geometry.height / 2,
499
+ testID: node.testID ?? null,
500
+ text: nodeText(node) || query,
501
+ type: node.type,
502
+ }
503
+ }
504
+ return payloadFromResolved(resolved, node, { total, idx })
505
+ },
546
506
  })
547
507
  }
548
508
 
@@ -551,72 +511,40 @@ export async function tapBest(
551
511
  query: string,
552
512
  opts: { agent?: boolean; timing?: TapTimingOptions } = {},
553
513
  ): Promise<TapOutcome> {
554
- const arg = JSON.stringify(query)
555
514
  return tapResolvedTarget(bridge, {
556
515
  agent: opts.agent,
557
516
  timing: opts.timing,
558
517
  textFallback: query,
559
518
  resolve: async () => {
560
- const payload = await bridge.send({
561
- type: 'evaluate',
562
- code: `(async () => {
563
- const t = window.__sootsimTest
564
- if (!t) return { error: 'bridge-not-ready' }
565
- const byTestId =
566
- (await t.findByTestId(${arg})) || (await t.findById(${arg}))
567
- if (byTestId && byTestId.absolutePosition && byTestId.layout) {
568
- return {
569
- strategy: 'testid',
570
- node: {
571
- nodeId: byTestId.nodeId ?? null,
572
- id: byTestId.id,
573
- testID: byTestId.testID,
574
- type: byTestId.type,
575
- text: byTestId.text,
576
- absolutePosition: byTestId.absolutePosition,
577
- layout: byTestId.layout,
578
- },
579
- screen: ${SCREEN_EVAL},
580
- }
581
- }
582
- const byText = await t.findByText(${arg})
583
- if (byText && byText.absolutePosition && byText.layout) {
584
- return {
585
- strategy: 'text',
586
- node: {
587
- nodeId: byText.nodeId ?? null,
588
- id: byText.id,
589
- testID: byText.testID,
590
- type: byText.type,
591
- text: byText.text,
592
- absolutePosition: byText.absolutePosition,
593
- layout: byText.layout,
594
- },
595
- screen: ${SCREEN_EVAL},
596
- }
597
- }
598
- return { strategy: 'none' }
599
- })()`,
600
- })
601
- if (!payload || !('node' in payload)) return payload
602
- const node = payload.node
603
- const cx = node.absolutePosition.x + node.layout.width / 2
604
- const cy = node.absolutePosition.y + node.layout.height / 2
605
- const screen = payload.screen ?? null
606
- const offscreen =
607
- !!screen && (cx < 0 || cy < 0 || cx > screen.width || cy > screen.height)
608
- return {
609
- ...payload,
610
- cx,
611
- cy,
612
- ...(offscreen ? { offscreen: true, screen } : {}),
613
- target: {
614
- id: node.id,
615
- testID: node.testID,
616
- text: payload.strategy === 'text' ? query : node.text,
617
- type: node.type,
618
- },
519
+ const byId = await resolveTargetCoords(bridge, { mode: 'testid', value: query })
520
+ if (byId) {
521
+ return {
522
+ ...payloadFromResolved(byId, byId),
523
+ strategy: 'testid',
524
+ node: {
525
+ nodeId: null,
526
+ id: byId.testID ?? query,
527
+ testID: byId.testID ?? query,
528
+ type: byId.type ?? null,
529
+ text: byId.text ?? null,
530
+ },
531
+ }
532
+ }
533
+ const byText = await resolveTargetCoords(bridge, { mode: 'text', value: query })
534
+ if (byText) {
535
+ return {
536
+ ...payloadFromResolved(byText, byText),
537
+ strategy: 'text',
538
+ node: {
539
+ nodeId: null,
540
+ id: byText.testID ?? null,
541
+ testID: byText.testID ?? null,
542
+ type: byText.type ?? null,
543
+ text: byText.text ?? query,
544
+ },
545
+ }
619
546
  }
547
+ return { strategy: 'none' }
620
548
  },
621
549
  })
622
550
  }