@blario/react-native 0.1.3 → 0.1.5
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.
- package/README.md +16 -2
- package/dist/actions/scrolling.d.ts +4 -0
- package/dist/actions/select.d.ts +16 -0
- package/dist/actions/types.d.ts +3 -0
- package/dist/backend/app-info.d.ts +1 -0
- package/dist/fiber/text.d.ts +1 -0
- package/dist/snapshot/collect.d.ts +1 -0
- package/dist/snapshot/scroll.d.ts +2 -0
- package/dist/snapshot/view.d.ts +3 -0
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/actions/click.ts +3 -1
- package/src/actions/executor.ts +3 -0
- package/src/actions/landing.ts +2 -2
- package/src/actions/parse.ts +2 -0
- package/src/actions/scrolling.ts +8 -3
- package/src/actions/select.ts +162 -0
- package/src/actions/types.ts +1 -0
- package/src/backend/app-info.ts +3 -0
- package/src/chat/widget-bundle.generated.ts +2 -2
- package/src/fiber/text.ts +2 -0
- package/src/snapshot/capture.ts +1 -0
- package/src/snapshot/collect.ts +15 -2
- package/src/snapshot/render.ts +3 -1
- package/src/snapshot/scroll.ts +12 -0
- package/src/snapshot/view.ts +34 -0
- package/src/snapshot/wrappers.ts +2 -1
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -284,6 +284,16 @@ npx expo start --clear # Metro; the simulator reaches 127.0.
|
|
|
284
284
|
xcrun simctl launch "iPhone 17 iOS 26" io.blar.example
|
|
285
285
|
```
|
|
286
286
|
|
|
287
|
+
### On an Android emulator
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
npx expo prebuild -p android --no-install # it rewrites package.json's scripts: revert that
|
|
291
|
+
# newArchEnabled=true in android/gradle.properties, and sdk.dir in android/local.properties
|
|
292
|
+
(cd android && ./gradlew app:assembleDebug)
|
|
293
|
+
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
|
294
|
+
adb reverse tcp:8081 tcp:8081 && adb reverse tcp:8790 tcp:8790
|
|
295
|
+
```
|
|
296
|
+
|
|
287
297
|
`.maestro/` holds [Maestro](https://maestro.dev) flows that tap through the app, chat WebView included
|
|
288
298
|
(`brew install mobile-dev-inc/tap/maestro`, needs Java 17): `maestro test .maestro/chat-keyboard.yaml`.
|
|
289
299
|
|
|
@@ -312,9 +322,10 @@ Wire envelope (mirrors the backend): `agent_command` / `request_dom_state` / `re
|
|
|
312
322
|
|
|
313
323
|
| `action.type` | Fields | Target |
|
|
314
324
|
| --- | --- | --- |
|
|
315
|
-
| `click` | | `btn-N` (a switch toggles, an input is focused) |
|
|
325
|
+
| `click` | | `btn-N` (a switch toggles, an input is focused, a wheel picker's row scrolls to the center) |
|
|
316
326
|
| `type` | `value`, `clear_first` (default true) | `inp-N` text input |
|
|
317
327
|
| `check` | `checked` (omit to toggle) | `inp-N` switch |
|
|
328
|
+
| `select` | `value` | `scr-N` or one of its rows: centers the row with that text, in one list at a time |
|
|
318
329
|
| `press_key` | `key` (`Enter` only) | `inp-N`, or the last input used |
|
|
319
330
|
| `scroll` | `direction` up/down/left/right, `amount` px | `scr-N` |
|
|
320
331
|
| `scroll_to` | | any element, uses its nearest scroll container |
|
|
@@ -322,7 +333,10 @@ Wire envelope (mirrors the backend): `agent_command` / `request_dom_state` / `re
|
|
|
322
333
|
| `highlight` | `duration_ms` (default 3000) | any element |
|
|
323
334
|
|
|
324
335
|
`click`, `type`, `check` and `highlight` scroll the target into view first. Elements that sit beyond their scroll
|
|
325
|
-
viewport are listed with `offscreen=above|below|left|right` (at most 30, nearest first).
|
|
336
|
+
viewport are listed with `offscreen=above|below|left|right` (at most 30, nearest first). A wheel picker is a snapping
|
|
337
|
+
list (`snapToInterval` or `snapToOffsets`) of plain text rows: each row is a `btn-N`, and all of them stay listed. Lists
|
|
338
|
+
carry `list=<name>` from their own or their wrapper's `accessibilityLabel` or `testID`, and so do those rows. `select`
|
|
339
|
+
needs no snap props: it reaches any list, so a wheel that snaps in JS works through it.
|
|
326
340
|
|
|
327
341
|
## Develop
|
|
328
342
|
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { type Band } from '../host/rect.ts';
|
|
2
|
+
import { type ScrollGeometry } from '../snapshot/scroll.ts';
|
|
2
3
|
import type { ResolvedTarget } from './target.ts';
|
|
3
4
|
import type { AgentAction, HandlerResult } from './types.ts';
|
|
4
5
|
type ScrollAction = Extract<AgentAction, {
|
|
5
6
|
type: 'scroll';
|
|
6
7
|
}>;
|
|
8
|
+
export declare function applyOffset(target: ResolvedTarget, geometry: ScrollGeometry, offset: number, animated: boolean): HandlerResult;
|
|
7
9
|
export declare function scrollContainer(target: ResolvedTarget, action: ScrollAction): Promise<HandlerResult>;
|
|
10
|
+
export declare function nearestScrollAncestor(target: ResolvedTarget): ResolvedTarget['fiber'] | null;
|
|
11
|
+
export declare function isInWheel(target: ResolvedTarget): boolean;
|
|
8
12
|
export declare function scrollIntoView(target: ResolvedTarget, options?: {
|
|
9
13
|
animated: boolean;
|
|
10
14
|
}): Promise<HandlerResult>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Fiber } from '../fiber/types.ts';
|
|
2
|
+
import type { ActionServices } from './services.ts';
|
|
3
|
+
import type { ResolvedTarget } from './target.ts';
|
|
4
|
+
import type { HandlerResult } from './types.ts';
|
|
5
|
+
interface ListRow {
|
|
6
|
+
host: Fiber;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function centerInList(target: ResolvedTarget, list: Fiber, row: Fiber): Promise<HandlerResult>;
|
|
10
|
+
export declare function scrollListTo(target: ResolvedTarget, list: Fiber, offset: number): Promise<HandlerResult>;
|
|
11
|
+
export declare function selectWheelOption(target: ResolvedTarget): Promise<HandlerResult>;
|
|
12
|
+
export declare function rowsOf(list: Fiber): ListRow[];
|
|
13
|
+
export declare function matchesRow(text: string, value: string): boolean;
|
|
14
|
+
export declare function selectValue(target: ResolvedTarget, value: string, services: ActionServices): Promise<HandlerResult>;
|
|
15
|
+
export declare function walk(from: number, to: number, step: number): number[];
|
|
16
|
+
export {};
|
package/dist/actions/types.d.ts
CHANGED
package/dist/fiber/text.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Fiber } from './types.ts';
|
|
2
|
+
export declare const TEXT_HOST_TYPE = "RCTText";
|
|
2
3
|
export declare function normalizeText(value: string): string;
|
|
3
4
|
export declare function collectText(fiber: Fiber, limit: number): string;
|
|
4
5
|
export declare function precedingText(ancestors: Fiber[], fiber: Fiber, limit: number): string;
|
|
@@ -7,9 +7,11 @@ export interface ScrollGeometry {
|
|
|
7
7
|
horizontal: boolean;
|
|
8
8
|
}
|
|
9
9
|
export declare function isHorizontal(fiber: Fiber): boolean;
|
|
10
|
+
export declare function isWheel(fiber: Fiber): boolean;
|
|
10
11
|
export declare function measureScrollGeometry(scrollFiber: Fiber): Promise<ScrollGeometry | null>;
|
|
11
12
|
export declare function scrollOffsetOf(geometry: ScrollGeometry): number;
|
|
12
13
|
export declare function maxScrollOffsetOf(geometry: ScrollGeometry): number;
|
|
13
14
|
export declare function viewportExtentOf(geometry: ScrollGeometry): number;
|
|
15
|
+
export declare function offsetCentering(rect: Rect, geometry: ScrollGeometry): number;
|
|
14
16
|
export declare function scrollDirectionsOf(geometry: ScrollGeometry): ScrollDirection[];
|
|
15
17
|
export declare function describeScrollDirections(geometry: ScrollGeometry | null): string;
|
package/dist/snapshot/view.d.ts
CHANGED
|
@@ -17,8 +17,10 @@ export interface ElementView {
|
|
|
17
17
|
selected: boolean | null;
|
|
18
18
|
scroll: string | null;
|
|
19
19
|
offscreen: Offscreen | null;
|
|
20
|
+
list: string | null;
|
|
20
21
|
rect: Rect;
|
|
21
22
|
}
|
|
23
|
+
export declare function listNameOf(list: Fiber, ancestors: Fiber[]): string | null;
|
|
22
24
|
export interface ViewSource {
|
|
23
25
|
kind: ElementKind;
|
|
24
26
|
fiber: Fiber;
|
|
@@ -26,5 +28,6 @@ export interface ViewSource {
|
|
|
26
28
|
rect: Rect;
|
|
27
29
|
scroll: string | null;
|
|
28
30
|
offscreen?: Offscreen | null;
|
|
31
|
+
wheelOption?: boolean;
|
|
29
32
|
}
|
|
30
33
|
export declare function buildView(source: ViewSource): ElementView;
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "0.1.
|
|
1
|
+
export declare const SDK_VERSION = "0.1.5";
|
package/package.json
CHANGED
package/src/actions/click.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { buildPressEvent } from './events.ts';
|
|
|
6
6
|
import { checkInteractable } from './interaction-guards.ts';
|
|
7
7
|
import { failed, failedFromException, succeeded } from './result.ts';
|
|
8
8
|
import type { ActionServices } from './services.ts';
|
|
9
|
+
import { isInWheel } from './scrolling.ts';
|
|
10
|
+
import { selectWheelOption } from './select.ts';
|
|
9
11
|
import type { ResolvedTarget } from './target.ts';
|
|
10
12
|
import type { HandlerResult } from './types.ts';
|
|
11
13
|
import { toggleSwitch } from './switch.ts';
|
|
@@ -49,6 +51,6 @@ export async function click(target: ResolvedTarget, services: ActionServices): P
|
|
|
49
51
|
case 'text_input':
|
|
50
52
|
return focusInput(target, services);
|
|
51
53
|
default:
|
|
52
|
-
return pressButton(target);
|
|
54
|
+
return !isFunction(target.props.onPress) && isInWheel(target) ? selectWheelOption(target) : pressButton(target);
|
|
53
55
|
}
|
|
54
56
|
}
|
package/src/actions/executor.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { highlight } from './highlight.ts';
|
|
|
5
5
|
import { tapsTarget, waitForLanding, waitsForLanding } from './landing.ts';
|
|
6
6
|
import { failed, failedFromException } from './result.ts';
|
|
7
7
|
import { scrollContainer, scrollIntoView } from './scrolling.ts';
|
|
8
|
+
import { selectValue } from './select.ts';
|
|
8
9
|
import { SYSTEM_CLOCK } from './settle.ts';
|
|
9
10
|
import type { ActionServices } from './services.ts';
|
|
10
11
|
import { waitForSettle } from './settle.ts';
|
|
@@ -132,6 +133,8 @@ export class ActionExecutor {
|
|
|
132
133
|
return typeText(target, action, this.services);
|
|
133
134
|
case 'check':
|
|
134
135
|
return toggleSwitch(target, action.checked);
|
|
136
|
+
case 'select':
|
|
137
|
+
return selectValue(target, action.value, this.services);
|
|
135
138
|
case 'press_key':
|
|
136
139
|
return action.key === 'Enter' ? submitInput(target) : failed('unsupported_action', `key ${action.key} is not supported`);
|
|
137
140
|
case 'scroll':
|
package/src/actions/landing.ts
CHANGED
|
@@ -9,9 +9,9 @@ export interface CursorPort {
|
|
|
9
9
|
|
|
10
10
|
export const LANDING_MAX_MS = 1800;
|
|
11
11
|
|
|
12
|
-
const TAP_ACTIONS: readonly AgentAction['type'][] = ['click', 'check', 'type'];
|
|
12
|
+
const TAP_ACTIONS: readonly AgentAction['type'][] = ['click', 'check', 'select', 'type'];
|
|
13
13
|
|
|
14
|
-
const LANDING_ACTIONS: readonly AgentAction['type'][] = ['click', 'check'];
|
|
14
|
+
const LANDING_ACTIONS: readonly AgentAction['type'][] = ['click', 'check', 'select'];
|
|
15
15
|
|
|
16
16
|
export function tapsTarget(action: AgentAction): boolean {
|
|
17
17
|
return TAP_ACTIONS.includes(action.type);
|
package/src/actions/parse.ts
CHANGED
|
@@ -32,6 +32,8 @@ function parseAction(raw: unknown): AgentAction | null {
|
|
|
32
32
|
return typeof raw.value === 'string' ? { type: 'type', value: raw.value, clear_first: raw.clear_first !== false } : null;
|
|
33
33
|
case 'check':
|
|
34
34
|
return { type: 'check', checked: typeof raw.checked === 'boolean' ? raw.checked : null };
|
|
35
|
+
case 'select':
|
|
36
|
+
return typeof raw.value === 'string' ? { type: 'select', value: raw.value } : null;
|
|
35
37
|
case 'press_key':
|
|
36
38
|
return typeof raw.key === 'string' ? { type: 'press_key', key: raw.key } : null;
|
|
37
39
|
case 'scroll':
|
package/src/actions/scrolling.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { measureHost } from '../host/measure.ts';
|
|
2
2
|
import { type Band, scrollDeltaIntoBand } from '../host/rect.ts';
|
|
3
3
|
import { scrollInstanceOf } from '../snapshot/classify.ts';
|
|
4
|
-
import { maxScrollOffsetOf, measureScrollGeometry, scrollOffsetOf, type ScrollGeometry, viewportExtentOf } from '../snapshot/scroll.ts';
|
|
4
|
+
import { isWheel, maxScrollOffsetOf, measureScrollGeometry, scrollOffsetOf, type ScrollGeometry, viewportExtentOf } from '../snapshot/scroll.ts';
|
|
5
5
|
import { failed, failedFromException, succeeded } from './result.ts';
|
|
6
6
|
import type { ResolvedTarget } from './target.ts';
|
|
7
7
|
import type { AgentAction, HandlerResult } from './types.ts';
|
|
@@ -16,7 +16,7 @@ function clamp(value: number, minimum: number, maximum: number): number {
|
|
|
16
16
|
return Math.min(Math.max(value, minimum), maximum);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function applyOffset(target: ResolvedTarget, geometry: ScrollGeometry, offset: number, animated: boolean): HandlerResult {
|
|
19
|
+
export function applyOffset(target: ResolvedTarget, geometry: ScrollGeometry, offset: number, animated: boolean): HandlerResult {
|
|
20
20
|
const instance = scrollInstanceOf(target.fiber);
|
|
21
21
|
if (instance === null) {
|
|
22
22
|
return failed('element_not_interactable', 'the scroll container has no scrollTo');
|
|
@@ -51,7 +51,7 @@ export async function scrollContainer(target: ResolvedTarget, action: ScrollActi
|
|
|
51
51
|
return applyOffset(target, geometry, next, true);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function nearestScrollAncestor(target: ResolvedTarget): ResolvedTarget['fiber'] | null {
|
|
54
|
+
export function nearestScrollAncestor(target: ResolvedTarget): ResolvedTarget['fiber'] | null {
|
|
55
55
|
for (let index = target.ancestors.length - 1; index >= 0; index -= 1) {
|
|
56
56
|
const ancestor = target.ancestors[index];
|
|
57
57
|
if (ancestor !== undefined && scrollInstanceOf(ancestor) !== null) {
|
|
@@ -61,6 +61,11 @@ function nearestScrollAncestor(target: ResolvedTarget): ResolvedTarget['fiber']
|
|
|
61
61
|
return null;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
export function isInWheel(target: ResolvedTarget): boolean {
|
|
65
|
+
const container = nearestScrollAncestor(target);
|
|
66
|
+
return container !== null && isWheel(container);
|
|
67
|
+
}
|
|
68
|
+
|
|
64
69
|
function offsetDeltaToReveal(start: number, end: number, viewportStart: number, viewportEnd: number): number {
|
|
65
70
|
const startShortfall = start - (viewportStart + VISIBLE_MARGIN);
|
|
66
71
|
if (startShortfall < 0) {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { isFunction } from '../fiber/narrow.ts';
|
|
4
|
+
import { childrenOf, isHostComponent, propsOf } from '../fiber/reader.ts';
|
|
5
|
+
import { collectText, TEXT_HOST_TYPE } from '../fiber/text.ts';
|
|
6
|
+
import type { Fiber } from '../fiber/types.ts';
|
|
7
|
+
import { measureHost } from '../host/measure.ts';
|
|
8
|
+
import { maxScrollOffsetOf, measureScrollGeometry, offsetCentering, type ScrollGeometry, scrollOffsetOf, viewportExtentOf } from '../snapshot/scroll.ts';
|
|
9
|
+
import { runBatched } from './batched.ts';
|
|
10
|
+
import { failed, succeeded } from './result.ts';
|
|
11
|
+
import { applyOffset, isInWheel, nearestScrollAncestor } from './scrolling.ts';
|
|
12
|
+
import { SYSTEM_CLOCK } from './settle.ts';
|
|
13
|
+
import type { ActionServices } from './services.ts';
|
|
14
|
+
import type { ResolvedTarget } from './target.ts';
|
|
15
|
+
import type { HandlerResult } from './types.ts';
|
|
16
|
+
|
|
17
|
+
const ROW_TEXT_LIMIT = 80;
|
|
18
|
+
const MOUNT_WAIT_MS = 600;
|
|
19
|
+
const WALK_STEP_FRACTION = 3;
|
|
20
|
+
const WALK_SETTLE_MS = 120;
|
|
21
|
+
const MOUNT_POLL_MS = 150;
|
|
22
|
+
const LISTED_OPTIONS = 30;
|
|
23
|
+
const MOVEMENT_TOLERANCE = 1;
|
|
24
|
+
const SCROLL_ANIMATION_MS = 400;
|
|
25
|
+
|
|
26
|
+
interface ListRow {
|
|
27
|
+
host: Fiber;
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// iOS ends an animated scrollTo in onMomentumScrollEnd, the event a wheel commits its value on, as a user's swipe does.
|
|
32
|
+
// Android only fires it after a fling, so the wheel is told here.
|
|
33
|
+
function commitScrollEnd(list: Fiber, geometry: ScrollGeometry, offset: number): void {
|
|
34
|
+
const onMomentumScrollEnd = propsOf(list).onMomentumScrollEnd;
|
|
35
|
+
if (Platform.OS !== 'android' || !isFunction(onMomentumScrollEnd)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const { viewport, content, horizontal } = geometry;
|
|
39
|
+
runBatched(() =>
|
|
40
|
+
onMomentumScrollEnd({
|
|
41
|
+
nativeEvent: {
|
|
42
|
+
contentOffset: horizontal ? { x: offset, y: 0 } : { x: 0, y: offset },
|
|
43
|
+
contentSize: { width: content.width, height: content.height },
|
|
44
|
+
layoutMeasurement: { width: viewport.width, height: viewport.height },
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function centerInList(target: ResolvedTarget, list: Fiber, row: Fiber): Promise<HandlerResult> {
|
|
51
|
+
const [geometry, rect] = await Promise.all([measureScrollGeometry(list), measureHost(row)]);
|
|
52
|
+
if (geometry === null || rect === null) {
|
|
53
|
+
return failed('element_not_found', 'the option or its list could not be measured');
|
|
54
|
+
}
|
|
55
|
+
const next = offsetCentering(rect, geometry);
|
|
56
|
+
if (Math.abs(next - scrollOffsetOf(geometry)) < MOVEMENT_TOLERANCE) {
|
|
57
|
+
commitScrollEnd(list, geometry, next);
|
|
58
|
+
return succeeded('the option is already selected');
|
|
59
|
+
}
|
|
60
|
+
const moved = applyOffset({ ...target, fiber: list }, geometry, next, true);
|
|
61
|
+
if (moved.ok) {
|
|
62
|
+
await SYSTEM_CLOCK.sleep(SCROLL_ANIMATION_MS);
|
|
63
|
+
commitScrollEnd(list, geometry, next);
|
|
64
|
+
}
|
|
65
|
+
return moved;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function scrollListTo(target: ResolvedTarget, list: Fiber, offset: number): Promise<HandlerResult> {
|
|
69
|
+
const geometry = await measureScrollGeometry(list);
|
|
70
|
+
return geometry === null ? failed('element_not_found', 'the list could not be measured') : applyOffset({ ...target, fiber: list }, geometry, offset, false);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function selectWheelOption(target: ResolvedTarget): Promise<HandlerResult> {
|
|
74
|
+
const container = nearestScrollAncestor(target);
|
|
75
|
+
if (container === null || target.host === null) {
|
|
76
|
+
return failed('element_not_interactable', 'the option is not inside a wheel');
|
|
77
|
+
}
|
|
78
|
+
return centerInList(target, container, target.host);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function rowsOf(list: Fiber): ListRow[] {
|
|
82
|
+
const rows: ListRow[] = [];
|
|
83
|
+
const visit = (fiber: Fiber): void => {
|
|
84
|
+
if (isHostComponent(fiber) && fiber.type === TEXT_HOST_TYPE) {
|
|
85
|
+
const text = collectText(fiber, ROW_TEXT_LIMIT);
|
|
86
|
+
if (text.length > 0) {
|
|
87
|
+
rows.push({ host: fiber, text });
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
childrenOf(fiber).forEach(visit);
|
|
92
|
+
};
|
|
93
|
+
visit(list);
|
|
94
|
+
return rows;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function matchesRow(text: string, value: string): boolean {
|
|
98
|
+
const [left, right] = [text.trim().toLowerCase(), value.trim().toLowerCase()];
|
|
99
|
+
return left === right || (/^\d+$/.test(left) && /^\d+$/.test(right) && Number(left) === Number(right));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Any list counts when the agent names it, not only a snapping one: this is what reaches a wheel that snaps in JS.
|
|
103
|
+
function listOf(target: ResolvedTarget): Fiber | null {
|
|
104
|
+
if (target.element.kind === 'scroll') {
|
|
105
|
+
return target.fiber;
|
|
106
|
+
}
|
|
107
|
+
return isInWheel(target) ? nearestScrollAncestor(target) : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A virtualized list mounts its rows shortly after it opens, and a distant row only once scrolling gets near it: wait
|
|
111
|
+
// a moment, then walk the list. A value that isn't there leaves the list where it was.
|
|
112
|
+
export async function selectValue(target: ResolvedTarget, value: string, services: ActionServices): Promise<HandlerResult> {
|
|
113
|
+
const first = listOf(target);
|
|
114
|
+
if (first === null) {
|
|
115
|
+
return failed('element_not_interactable', 'select works on one open list: click this field to open its picker, then select each value on its list (scr-N)');
|
|
116
|
+
}
|
|
117
|
+
let current = target;
|
|
118
|
+
let list = first;
|
|
119
|
+
const seen = new Set<string>();
|
|
120
|
+
const findRow = (): ListRow | undefined => {
|
|
121
|
+
current = services.resolver.resolveElement(target.element) ?? current;
|
|
122
|
+
list = listOf(current) ?? list;
|
|
123
|
+
const rows = rowsOf(list);
|
|
124
|
+
rows.forEach((row) => seen.add(row.text));
|
|
125
|
+
return rows.find((row) => matchesRow(row.text, value));
|
|
126
|
+
};
|
|
127
|
+
let row = findRow();
|
|
128
|
+
for (let waited = 0; row === undefined && waited < MOUNT_WAIT_MS; waited += MOUNT_POLL_MS) {
|
|
129
|
+
await SYSTEM_CLOCK.sleep(MOUNT_POLL_MS);
|
|
130
|
+
row = findRow();
|
|
131
|
+
}
|
|
132
|
+
if (row !== undefined) {
|
|
133
|
+
return centerInList(current, list, row.host);
|
|
134
|
+
}
|
|
135
|
+
const geometry = await measureScrollGeometry(list);
|
|
136
|
+
if (geometry !== null) {
|
|
137
|
+
const start = scrollOffsetOf(geometry);
|
|
138
|
+
const step = Math.max(1, viewportExtentOf(geometry) * WALK_STEP_FRACTION);
|
|
139
|
+
const end = maxScrollOffsetOf(geometry);
|
|
140
|
+
const stops = [...walk(start, end, step), ...walk(start, 0, step)];
|
|
141
|
+
for (const stop of stops) {
|
|
142
|
+
await scrollListTo(current, list, stop);
|
|
143
|
+
await SYSTEM_CLOCK.sleep(WALK_SETTLE_MS);
|
|
144
|
+
row = findRow();
|
|
145
|
+
if (row !== undefined) {
|
|
146
|
+
return centerInList(current, list, row.host);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
await scrollListTo(current, list, start);
|
|
150
|
+
}
|
|
151
|
+
const options = [...seen].slice(0, LISTED_OPTIONS).join(', ');
|
|
152
|
+
return failed('element_not_found', `"${value}" is not in this list. Options: ${options}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function walk(from: number, to: number, step: number): number[] {
|
|
156
|
+
const stops: number[] = [];
|
|
157
|
+
const direction = to >= from ? 1 : -1;
|
|
158
|
+
for (let offset = from + direction * step; direction > 0 ? offset < to : offset > to; offset += direction * step) {
|
|
159
|
+
stops.push(offset);
|
|
160
|
+
}
|
|
161
|
+
return from === to ? stops : [...stops, to];
|
|
162
|
+
}
|
package/src/actions/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type AgentAction =
|
|
|
6
6
|
| { type: 'click' }
|
|
7
7
|
| { type: 'type'; value: string; clear_first: boolean }
|
|
8
8
|
| { type: 'check'; checked: boolean | null }
|
|
9
|
+
| { type: 'select'; value: string }
|
|
9
10
|
| { type: 'press_key'; key: string }
|
|
10
11
|
| { type: 'scroll'; direction: ScrollDirectionName; amount: number | null }
|
|
11
12
|
| { type: 'scroll_to' }
|
package/src/backend/app-info.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Platform } from 'react-native';
|
|
2
2
|
|
|
3
3
|
import { isRecord } from '../fiber/narrow.ts';
|
|
4
|
+
import { SDK_VERSION } from '../version.ts';
|
|
4
5
|
|
|
5
6
|
export interface AppInfo {
|
|
6
7
|
platform: 'react-native';
|
|
8
|
+
sdk_version: string;
|
|
7
9
|
os: string;
|
|
8
10
|
os_version: string;
|
|
9
11
|
rn_version: string;
|
|
@@ -32,6 +34,7 @@ export function detectNewArchitecture(markers: GlobalMarkers): boolean {
|
|
|
32
34
|
export function readAppInfo(): AppInfo {
|
|
33
35
|
return {
|
|
34
36
|
platform: 'react-native',
|
|
37
|
+
sdk_version: SDK_VERSION,
|
|
35
38
|
os: Platform.OS,
|
|
36
39
|
os_version: String(Platform.Version),
|
|
37
40
|
rn_version: readReactNativeVersion(),
|