@quiesco/agent 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +23 -2
- package/metro.cjs +144 -0
- package/package.json +54 -3
- package/src/actions/hit-test.ts +42 -0
- package/src/actions/host.ts +194 -0
- package/src/actions/js.ts +245 -0
- package/src/actions/multitouch.ts +179 -0
- package/src/actions/native.ts +500 -0
- package/src/actions/performer.ts +87 -0
- package/src/actions/value.ts +460 -0
- package/src/actions/wheel.ts +260 -0
- package/src/api-types.ts +32 -0
- package/src/api.ts +11 -0
- package/src/assertions/index.ts +160 -0
- package/src/backdoor.ts +54 -0
- package/src/build-config.ts +6 -0
- package/src/connection/client.ts +196 -0
- package/src/connection/params.ts +63 -0
- package/src/early.ts +10 -0
- package/src/errors.ts +23 -0
- package/src/events.ts +136 -0
- package/src/expo-fetch.js +13 -0
- package/src/fiber-adapter/default-deps.native.ts +36 -0
- package/src/fiber-adapter/default-deps.ts +38 -0
- package/src/fiber-adapter/fiber.ts +81 -0
- package/src/fiber-adapter/hook.ts +154 -0
- package/src/fiber-adapter/host.ts +210 -0
- package/src/fiber-adapter/index.ts +221 -0
- package/src/fiber-adapter/scheduler.ts +70 -0
- package/src/fiber-adapter/traverse.ts +260 -0
- package/src/fiber-adapter/types.ts +142 -0
- package/src/index.ts +7 -0
- package/src/install.ts +6 -0
- package/src/json-check.ts +43 -0
- package/src/platform.ts +155 -0
- package/src/query/index.ts +5 -0
- package/src/query/matcher.ts +121 -0
- package/src/query/semantic.ts +266 -0
- package/src/query/tree.ts +116 -0
- package/src/query/visibility.ts +60 -0
- package/src/reset/index.ts +59 -0
- package/src/reset/storage-defaults.native.ts +26 -0
- package/src/reset/storage-defaults.ts +39 -0
- package/src/root/QuiescoRoot.tsx +28 -0
- package/src/runtime.ts +1003 -0
- package/src/singleton.ts +18 -0
- package/src/stub.ts +28 -0
- package/src/sync/frame-stable.ts +142 -0
- package/src/sync/idle-callback.ts +142 -0
- package/src/sync/index.ts +17 -0
- package/src/sync/native-settle.ts +41 -0
- package/src/sync/network.ts +299 -0
- package/src/sync/offline.ts +121 -0
- package/src/sync/react.ts +28 -0
- package/src/sync/timer.ts +124 -0
- package/src/sync/tracker.ts +51 -0
- package/src/sync/wait-for-idle.ts +141 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `native` action mode (requirements.md §6.3): real input through @quiesco/native, so hit testing,
|
|
3
|
+
* gesture handlers, pressed states and focus behave as for a user.
|
|
4
|
+
*
|
|
5
|
+
* Before touching an element:
|
|
6
|
+
* 1. its native view must stay at the same place for two consecutive checks, and the native side must
|
|
7
|
+
* not report it as settling (iOS: a view controller transition such as a Modal fading in drops
|
|
8
|
+
* touches without moving the view);
|
|
9
|
+
* 2. its window must have the input focus (otherwise e.g. a Modal is on top) → ELEMENT_OBSCURED;
|
|
10
|
+
* 3. within the window, the topmost element at its center must be the element or a descendant
|
|
11
|
+
* (elements with pointerEvents none / box-none are transparent) → ELEMENT_OBSCURED.
|
|
12
|
+
* The touch point itself is computed natively from the view (docs/spikes, Phase 2.0 findings).
|
|
13
|
+
*/
|
|
14
|
+
import type { ActionType, Direction, KeyName, Matcher, Point, Rect, Speed } from '@quiesco/protocol';
|
|
15
|
+
|
|
16
|
+
import { QuiescoError } from '../errors';
|
|
17
|
+
import type { HostNode, Viewport } from '../fiber-adapter';
|
|
18
|
+
import { describeMatcher, summarize } from '../query';
|
|
19
|
+
import type { Clock } from '../sync';
|
|
20
|
+
import { center, describe, hitTest, isAncestor } from './hit-test';
|
|
21
|
+
import type { ActionPerformer } from './performer';
|
|
22
|
+
import type { FingerPath } from './multitouch';
|
|
23
|
+
import { framesForSequence, pinchGestures, rotateGesture } from './multitouch';
|
|
24
|
+
import * as value from './value';
|
|
25
|
+
import type { WheelContext, WheelState } from './wheel';
|
|
26
|
+
import { selectDate, selectRow } from './wheel';
|
|
27
|
+
|
|
28
|
+
/** The parts of @quiesco/native used here (see packages/native/src/index.ts). */
|
|
29
|
+
export interface NativeBridge {
|
|
30
|
+
locate(tag: number): Promise<{
|
|
31
|
+
found: boolean;
|
|
32
|
+
x?: number;
|
|
33
|
+
y?: number;
|
|
34
|
+
width?: number;
|
|
35
|
+
height?: number;
|
|
36
|
+
windowHasFocus?: boolean;
|
|
37
|
+
/** iOS: a view controller transition or a Core Animation is running; touches may be dropped. */
|
|
38
|
+
settling?: boolean;
|
|
39
|
+
/** iOS: class of the native view a touch at the center would reach. */
|
|
40
|
+
hitClass?: string;
|
|
41
|
+
/** iOS: tags of the hit view and its superviews (React tags on Fabric component views). */
|
|
42
|
+
hitTags?: number[];
|
|
43
|
+
}>;
|
|
44
|
+
/** iOS: reach information for a window point of `tag`'s window (taps at a point). */
|
|
45
|
+
hitAt?(tag: number, x: number, y: number): Promise<{ windowHasFocus?: boolean; hitClass?: string; hitTags?: number[] }>;
|
|
46
|
+
tap(tag: number, x: number, y: number, holdMs: number, count: number): Promise<{ handled: boolean }>;
|
|
47
|
+
swipe(tag: number, fromX: number, fromY: number, toX: number, toY: number, durationMs: number): Promise<{ handled: boolean }>;
|
|
48
|
+
typeText(tag: number, text: string, replace: boolean): Promise<{ text: string }>;
|
|
49
|
+
/** One finger along a timed path, flat [x, y, tMs, ...] in the window of `tag`. Absent in apps built before Phase 6.2. */
|
|
50
|
+
gesture?(tag: number, path: number[]): Promise<{ handled: boolean }>;
|
|
51
|
+
/** Absent in apps built before Phase 6.2. */
|
|
52
|
+
pressKey?(tag: number, key: KeyName): Promise<{ text: string }>;
|
|
53
|
+
/** Present when the module can open a URL inside the app (iOS: no system confirmation alert). */
|
|
54
|
+
openUrl?(url: string): Promise<{ opened: boolean }>;
|
|
55
|
+
/** iOS: native UI settling, sampled every frame (see sync/native-settle.ts). */
|
|
56
|
+
idleState?(): { settling: boolean; reasons: string[] };
|
|
57
|
+
/** Slider geometry (window x of the thumb at min / max, current thumb). Absent before Phase 6.3. */
|
|
58
|
+
sliderInfo?(tag: number): Promise<{ found: boolean; minX?: number; maxX?: number; thumbX?: number; y?: number; value?: number; minimum?: number; maximum?: number }>;
|
|
59
|
+
/** iOS only: wheel picker state (columns, selection, date). Absent before Phase 6.3 and on Android. */
|
|
60
|
+
wheelInfo?(tag: number): Promise<WheelState>;
|
|
61
|
+
/** Several fingers along timed paths (frames, see actions/multitouch.ts). Absent before Phase 6.4. */
|
|
62
|
+
multiGesture?(tag: number, fingerCount: number, frames: number[]): Promise<{ handled: boolean }>;
|
|
63
|
+
/** Perform a declared accessibility action as a screen reader does. Absent before Phase 6.3. */
|
|
64
|
+
accessibilityAction?(tag: number, name: string, label: string, index: number, customIndex: number): Promise<{ performed: boolean; via: string }>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface NativeContext {
|
|
68
|
+
bridge: NativeBridge;
|
|
69
|
+
clock: Clock;
|
|
70
|
+
roots: () => readonly HostNode[];
|
|
71
|
+
viewport: () => Viewport | null;
|
|
72
|
+
/** The current action and its matcher, for error data. */
|
|
73
|
+
action: ActionType;
|
|
74
|
+
matcher: Matcher;
|
|
75
|
+
deadline: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const TAP_HOLD_MS = 50;
|
|
79
|
+
const LONG_PRESS_MS = 800;
|
|
80
|
+
const STABLE_POLL_MS = 16;
|
|
81
|
+
const SWIPE_MS: Record<Speed, number> = { slow: 600, normal: 300, fast: 120 };
|
|
82
|
+
/** Slow drag for scrolling: no fling, so the content moves by about the drag distance. */
|
|
83
|
+
const SCROLL_DRAG_MS = 500;
|
|
84
|
+
const DRAG_FRACTION = 0.35;
|
|
85
|
+
/** Rest at the drop point before releasing, so drop targets see the finger arrive. */
|
|
86
|
+
const DROP_HOLD_MS = 100;
|
|
87
|
+
/** Press on the slider thumb before moving, so the slider (not a scroll view) takes the drag. */
|
|
88
|
+
const SLIDER_PRESS_MS = 80;
|
|
89
|
+
/** Every slider drag ends with a straight move of this length onto the aim (beyond the touch slop). */
|
|
90
|
+
const SLIDER_RUN_UP = 80;
|
|
91
|
+
|
|
92
|
+
/** The current @quiesco/native lacks `fn` (the app was built before it existed). */
|
|
93
|
+
function needsRebuild(fn: string, action: ActionType): QuiescoError {
|
|
94
|
+
return new QuiescoError('ACTION_NOT_SUPPORTED', `${action} needs @quiesco/native's ${fn}(), which the installed app does not have`, {
|
|
95
|
+
action,
|
|
96
|
+
platform: 'native',
|
|
97
|
+
hint: 'rebuild the app (e2e build) with the current @quiesco/native, or pass { mode: "js" } to this call',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { center, describe, hitTest, isAncestor } from './hit-test';
|
|
102
|
+
|
|
103
|
+
function tagOf(node: HostNode, action: ActionType, matcher: Matcher): number {
|
|
104
|
+
const tag = node.nativeTag();
|
|
105
|
+
if (tag === null) {
|
|
106
|
+
throw new QuiescoError('ACTION_NOT_SUPPORTED', 'native mode cannot address this element (no native view tag)', {
|
|
107
|
+
action,
|
|
108
|
+
platform: 'native',
|
|
109
|
+
hint: `element ${JSON.stringify(matcher)} has no native view; use mode 'js' for this call`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return tag;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Rejections of @quiesco/native (its NativeErrorCode) as Quiesco errors; anything else unchanged.
|
|
117
|
+
*/
|
|
118
|
+
export function fromNativeError(e: unknown, call: string, action: ActionType, matcher: Matcher): unknown {
|
|
119
|
+
const code = typeof e === 'object' && e !== null ? (e as { code?: unknown }).code : undefined;
|
|
120
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
121
|
+
switch (code) {
|
|
122
|
+
case 'E_UNSUPPORTED':
|
|
123
|
+
return new QuiescoError('ACTION_NOT_SUPPORTED', message, {
|
|
124
|
+
action,
|
|
125
|
+
platform: 'native',
|
|
126
|
+
hint: "real touch injection is not available on this OS version; set actions.mode to 'js' (or pass { mode: 'js' } to this call)",
|
|
127
|
+
});
|
|
128
|
+
case 'E_NO_VIEW':
|
|
129
|
+
return new QuiescoError('ELEMENT_NOT_FOUND', `The element's native view disappeared during ${action} (native ${call}; unmounted?)`, { matcher });
|
|
130
|
+
case 'E_NOT_TEXT_INPUT':
|
|
131
|
+
return new QuiescoError('ACTION_NOT_SUPPORTED', `${describeMatcher(matcher)} is not a text field: ${message}`, { action, platform: 'native' });
|
|
132
|
+
case 'E_NO_ACTION':
|
|
133
|
+
return new QuiescoError('ACTION_NOT_SUPPORTED', `${describeMatcher(matcher)}: ${message}`, { action, platform: 'native' });
|
|
134
|
+
default:
|
|
135
|
+
return e;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The bridge with rejections mapped by {@link fromNativeError}. */
|
|
140
|
+
function guarded(bridge: NativeBridge, action: ActionType, matcher: Matcher): NativeBridge {
|
|
141
|
+
const wrap =
|
|
142
|
+
<A extends unknown[], R>(name: string, fn: (...a: A) => Promise<R>) =>
|
|
143
|
+
(...a: A): Promise<R> =>
|
|
144
|
+
fn(...a).catch((e: unknown) => {
|
|
145
|
+
throw fromNativeError(e, name, action, matcher);
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
locate: wrap('locate', bridge.locate.bind(bridge)),
|
|
149
|
+
...(bridge.hitAt !== undefined ? { hitAt: wrap('hitAt', bridge.hitAt.bind(bridge)) } : {}),
|
|
150
|
+
tap: wrap('tap', bridge.tap.bind(bridge)),
|
|
151
|
+
swipe: wrap('swipe', bridge.swipe.bind(bridge)),
|
|
152
|
+
typeText: wrap('typeText', bridge.typeText.bind(bridge)),
|
|
153
|
+
...(bridge.gesture !== undefined ? { gesture: wrap('gesture', bridge.gesture.bind(bridge)) } : {}),
|
|
154
|
+
...(bridge.pressKey !== undefined ? { pressKey: wrap('pressKey', bridge.pressKey.bind(bridge)) } : {}),
|
|
155
|
+
...(bridge.sliderInfo !== undefined ? { sliderInfo: wrap('sliderInfo', bridge.sliderInfo.bind(bridge)) } : {}),
|
|
156
|
+
...(bridge.wheelInfo !== undefined ? { wheelInfo: wrap('wheelInfo', bridge.wheelInfo.bind(bridge)) } : {}),
|
|
157
|
+
...(bridge.accessibilityAction !== undefined ? { accessibilityAction: wrap('accessibilityAction', bridge.accessibilityAction.bind(bridge)) } : {}),
|
|
158
|
+
...(bridge.multiGesture !== undefined ? { multiGesture: wrap('multiGesture', bridge.multiGesture.bind(bridge)) } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function nativePerformer(ctx: NativeContext): ActionPerformer {
|
|
163
|
+
const { clock } = ctx;
|
|
164
|
+
const bridge = guarded(ctx.bridge, ctx.action, ctx.matcher);
|
|
165
|
+
const sleep = (ms: number): Promise<void> =>
|
|
166
|
+
new Promise((r) => {
|
|
167
|
+
clock.setTimeout(r, ms);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Wait until the native view stops moving, then check it can receive the touch at `point` (element
|
|
172
|
+
* coordinates; default: its center).
|
|
173
|
+
*/
|
|
174
|
+
const ready = async (node: HostNode, point?: Point): Promise<{ tag: number; rect: Rect }> => {
|
|
175
|
+
const tag = tagOf(node, ctx.action, ctx.matcher);
|
|
176
|
+
let prev: Rect | null = null;
|
|
177
|
+
for (;;) {
|
|
178
|
+
const loc = await bridge.locate(tag);
|
|
179
|
+
if (!loc.found || loc.x === undefined || loc.y === undefined || loc.width === undefined || loc.height === undefined) {
|
|
180
|
+
// Rendered by React but not (yet) in a window, e.g. the content of a Modal being presented.
|
|
181
|
+
if (clock.now() < ctx.deadline) {
|
|
182
|
+
prev = null;
|
|
183
|
+
await sleep(STABLE_POLL_MS);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// `reason`: the host may close a system alert covering the app and try again (Phase 6.12).
|
|
187
|
+
throw new QuiescoError('ELEMENT_NOT_FOUND', 'The element has no native view in a window (unmounted?)', { matcher: ctx.matcher, reason: 'not-in-window' });
|
|
188
|
+
}
|
|
189
|
+
const rect = { x: loc.x, y: loc.y, width: loc.width, height: loc.height };
|
|
190
|
+
const same = loc.settling !== true && prev !== null && Math.abs(prev.x - rect.x) < 0.5 && Math.abs(prev.y - rect.y) < 0.5 && Math.abs(prev.width - rect.width) < 0.5;
|
|
191
|
+
if (same || clock.now() >= ctx.deadline) {
|
|
192
|
+
const reach = point !== undefined && bridge.hitAt !== undefined ? await bridge.hitAt(tag, rect.x + point.x, rect.y + point.y) : loc;
|
|
193
|
+
if (reach.windowHasFocus === false && !hitInside(node, reach.hitTags)) {
|
|
194
|
+
const what = reach.hitClass !== undefined && reach.hitClass !== '' ? ` (the touch would reach ${reach.hitClass})` : '';
|
|
195
|
+
throw obscured(node, `another window or native view, e.g. a Modal, dialog or navigation bar${what}`, { ...loc, ...reach });
|
|
196
|
+
}
|
|
197
|
+
const js = node.measure() ?? rect;
|
|
198
|
+
const top = hitTest(ctx.roots(), point === undefined ? center(js) : { x: js.x + point.x, y: js.y + point.y });
|
|
199
|
+
if (top !== null && !isAncestor(node, top)) throw obscured(node, describe(top));
|
|
200
|
+
return { tag, rect };
|
|
201
|
+
}
|
|
202
|
+
prev = rect;
|
|
203
|
+
await sleep(STABLE_POLL_MS);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Whether the native hit (iOS `hitTags`, from the hit view up) lands in `node`'s React subtree: the
|
|
209
|
+
* first tag that belongs to a React host must be the node or one of its descendants. Fabric mounts
|
|
210
|
+
* some React children outside their React parent's native view, so native ancestry alone is wrong.
|
|
211
|
+
*/
|
|
212
|
+
const hitInside = (node: HostNode, hitTags: number[] | undefined): boolean => {
|
|
213
|
+
if (hitTags === undefined) return false;
|
|
214
|
+
const inside = new Set<number>();
|
|
215
|
+
const collect = (n: HostNode, into: Set<number>): void => {
|
|
216
|
+
const t = n.nativeTag();
|
|
217
|
+
if (t !== null) into.add(t);
|
|
218
|
+
for (const c of n.children) collect(c, into);
|
|
219
|
+
};
|
|
220
|
+
collect(node, inside);
|
|
221
|
+
const known = new Set<number>();
|
|
222
|
+
for (const r of ctx.roots()) collect(r, known);
|
|
223
|
+
const first = hitTags.find((t) => known.has(t));
|
|
224
|
+
return first !== undefined && inside.has(first);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const obscured = (node: HostNode, by: string, native?: Record<string, unknown>): QuiescoError =>
|
|
228
|
+
new QuiescoError('ELEMENT_OBSCURED', `${describe(node)} is covered by ${by}; a real touch would not reach it`, {
|
|
229
|
+
matcher: ctx.matcher,
|
|
230
|
+
element: summarize(node, ctx.viewport()),
|
|
231
|
+
obscuredBy: by,
|
|
232
|
+
...(native !== undefined ? { native } : {}),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Finger drag inside the element, in its window coordinates. Without `from` it runs through the
|
|
237
|
+
* center; with `from` (normalized 0–1) it starts there and travels `distance` (default: about 70% of
|
|
238
|
+
* the element) in the finger direction.
|
|
239
|
+
*/
|
|
240
|
+
const drag = async (node: HostNode, finger: Direction, durationMs: number, distance?: number, from?: Point): Promise<void> => {
|
|
241
|
+
const { tag, rect } = await ready(node);
|
|
242
|
+
const vertical = finger === 'up' || finger === 'down';
|
|
243
|
+
const span = vertical ? rect.height : rect.width;
|
|
244
|
+
const sign = finger === 'down' || finger === 'right' ? 1 : -1;
|
|
245
|
+
let start: Point;
|
|
246
|
+
let end: Point;
|
|
247
|
+
if (from === undefined) {
|
|
248
|
+
const c = center(rect);
|
|
249
|
+
const half = Math.min((distance ?? span * DRAG_FRACTION * 2) / 2, span * 0.45);
|
|
250
|
+
start = vertical ? { x: c.x, y: c.y - sign * half } : { x: c.x - sign * half, y: c.y };
|
|
251
|
+
end = vertical ? { x: c.x, y: c.y + sign * half } : { x: c.x + sign * half, y: c.y };
|
|
252
|
+
} else {
|
|
253
|
+
start = { x: rect.x + from.x * rect.width, y: rect.y + from.y * rect.height };
|
|
254
|
+
const d = distance ?? span * DRAG_FRACTION * 2;
|
|
255
|
+
end = vertical ? { x: start.x, y: start.y + sign * d } : { x: start.x + sign * d, y: start.y };
|
|
256
|
+
}
|
|
257
|
+
await bridge.swipe(tag, start.x, start.y, end.x, end.y, durationMs);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** A one-finger timed path; points are window coordinates. */
|
|
261
|
+
const gesture = async (tag: number, path: [Point, number][]): Promise<void> => {
|
|
262
|
+
if (bridge.gesture === undefined) throw needsRebuild('gesture', ctx.action);
|
|
263
|
+
await bridge.gesture(tag, path.flatMap(([p, t]) => [p.x, p.y, t]));
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** Hold at an element-relative point for `holdMs` (a tap or a long press there). */
|
|
267
|
+
const pressAt = async (node: HostNode, point: Point, holdMs: number): Promise<void> => {
|
|
268
|
+
const { tag, rect } = await ready(node, point);
|
|
269
|
+
const p = { x: rect.x + point.x, y: rect.y + point.y };
|
|
270
|
+
await gesture(tag, [
|
|
271
|
+
[p, 0],
|
|
272
|
+
[p, holdMs],
|
|
273
|
+
]);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** Content direction → finger direction (scroll down = finger moves up). */
|
|
277
|
+
const fingerFor = (content: Direction): Direction => ({ up: 'down', down: 'up', left: 'right', right: 'left' } as const)[content];
|
|
278
|
+
|
|
279
|
+
const tooSmall = (e: unknown): never => {
|
|
280
|
+
throw new QuiescoError('ACTION_NOT_SUPPORTED', e instanceof Error ? e.message : String(e), { action: ctx.action, platform: 'native' });
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
viaHost: false,
|
|
285
|
+
async pinch(node, scale, angle, speed, slop) {
|
|
286
|
+
const { tag, rect } = await ready(node);
|
|
287
|
+
let gestures: FingerPath[][] = [];
|
|
288
|
+
try {
|
|
289
|
+
gestures = pinchGestures(rect, scale, angle, speed, ctx.viewport(), slop);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
tooSmall(e);
|
|
292
|
+
}
|
|
293
|
+
await playFingers(tag, gestures);
|
|
294
|
+
},
|
|
295
|
+
async rotate(node, radians, speed, slop) {
|
|
296
|
+
const { tag, rect } = await ready(node);
|
|
297
|
+
await playFingers(tag, [rotateGesture(rect, radians, speed, slop)]);
|
|
298
|
+
},
|
|
299
|
+
async multiTouch(node, fingers) {
|
|
300
|
+
const tag = node === null ? -1 : (await ready(node)).tag;
|
|
301
|
+
await playFingers(tag, [fingers]);
|
|
302
|
+
},
|
|
303
|
+
async tap(node, point) {
|
|
304
|
+
if (point !== undefined) return pressAt(node, point, TAP_HOLD_MS);
|
|
305
|
+
const { tag } = await ready(node);
|
|
306
|
+
await bridge.tap(tag, 0, 0, TAP_HOLD_MS, 1);
|
|
307
|
+
},
|
|
308
|
+
async longPress(node, durationMs, point) {
|
|
309
|
+
if (point !== undefined) return pressAt(node, point, durationMs ?? LONG_PRESS_MS);
|
|
310
|
+
const { tag } = await ready(node);
|
|
311
|
+
await bridge.tap(tag, 0, 0, durationMs ?? LONG_PRESS_MS, 1);
|
|
312
|
+
},
|
|
313
|
+
async multiTap(node, count) {
|
|
314
|
+
const { tag } = await ready(node);
|
|
315
|
+
await bridge.tap(tag, 0, 0, TAP_HOLD_MS, count);
|
|
316
|
+
},
|
|
317
|
+
async type(node, text) {
|
|
318
|
+
const { tag } = await ready(node);
|
|
319
|
+
await bridge.tap(tag, 0, 0, TAP_HOLD_MS, 1); // focus, like a user
|
|
320
|
+
await bridge.typeText(tag, text, false);
|
|
321
|
+
},
|
|
322
|
+
async replaceText(node, text) {
|
|
323
|
+
const { tag } = await ready(node);
|
|
324
|
+
await bridge.tap(tag, 0, 0, TAP_HOLD_MS, 1);
|
|
325
|
+
await bridge.typeText(tag, text, true);
|
|
326
|
+
},
|
|
327
|
+
async clear(node) {
|
|
328
|
+
const { tag } = await ready(node);
|
|
329
|
+
await bridge.tap(tag, 0, 0, TAP_HOLD_MS, 1);
|
|
330
|
+
await bridge.typeText(tag, '', true);
|
|
331
|
+
},
|
|
332
|
+
async pressKey(node, key) {
|
|
333
|
+
if (bridge.pressKey === undefined) throw needsRebuild('pressKey', ctx.action);
|
|
334
|
+
// The key goes to the field; it does not need to be visible or tapped (Detox #4546: the return
|
|
335
|
+
// key of the keyboard can be out of reach).
|
|
336
|
+
await bridge.pressKey(tagOf(node, ctx.action, ctx.matcher), key);
|
|
337
|
+
},
|
|
338
|
+
scroll: (node, direction, distance, from) => drag(node, fingerFor(direction), SCROLL_DRAG_MS, distance, from),
|
|
339
|
+
swipe: (node, direction, speed, distance, from) => drag(node, direction, SWIPE_MS[speed ?? 'normal'], distance, from),
|
|
340
|
+
async drag(node, to, holdMs, durationMs) {
|
|
341
|
+
const { tag, rect } = await ready(node);
|
|
342
|
+
const from = center(rect);
|
|
343
|
+
await gesture(tag, [
|
|
344
|
+
[from, 0],
|
|
345
|
+
[from, holdMs],
|
|
346
|
+
[to, holdMs + durationMs],
|
|
347
|
+
[to, holdMs + durationMs + DROP_HOLD_MS],
|
|
348
|
+
]);
|
|
349
|
+
},
|
|
350
|
+
async tapAt(point, holdMs) {
|
|
351
|
+
await bridge.tap(-1, point.x, point.y, holdMs ?? TAP_HOLD_MS, 1);
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Real input where the control has it: a switch is tapped (when it must change), a segment is
|
|
356
|
+
* tapped, an iOS wheel (Picker, DatePicker in wheels style) is turned. Sliders get their exact
|
|
357
|
+
* value through their callbacks (a drag lands on a pixel, not a value: use adjustSlider for a real
|
|
358
|
+
* drag); Android's picker dialogs / date dialogs, iOS compact / inline date pickers and components
|
|
359
|
+
* without a native view are set through their callbacks too (docs/api.mdx, value controls).
|
|
360
|
+
*/
|
|
361
|
+
async setValue(control, v) {
|
|
362
|
+
const node = control.node;
|
|
363
|
+
const viaCallbacks = (): void => value.jsSetValue(control, v, 'native');
|
|
364
|
+
if (node === null || node.nativeTag() === null || value.isDisabledControl(control)) return viaCallbacks();
|
|
365
|
+
switch (control.kind) {
|
|
366
|
+
case 'switch':
|
|
367
|
+
if (typeof v !== 'boolean' || value.switchState(control) === v) return viaCallbacks();
|
|
368
|
+
return this.tap(node);
|
|
369
|
+
case 'segmented': {
|
|
370
|
+
const i = value.segmentIndex(control, v, 'native');
|
|
371
|
+
const n = value.segmentValues(control).length;
|
|
372
|
+
const r = node.measure();
|
|
373
|
+
if (i === control.props['selectedIndex'] || r === null || n === 0) return viaCallbacks();
|
|
374
|
+
return pressAt(node, { x: ((i + 0.5) * r.width) / n, y: r.height / 2 }, TAP_HOLD_MS);
|
|
375
|
+
}
|
|
376
|
+
case 'picker':
|
|
377
|
+
case 'date': {
|
|
378
|
+
if (bridge.wheelInfo === undefined) return viaCallbacks();
|
|
379
|
+
const { tag } = await ready(node);
|
|
380
|
+
const wheels = wheelContext(tag);
|
|
381
|
+
if (!(await wheels.read()).found) return viaCallbacks();
|
|
382
|
+
if (control.kind === 'picker') {
|
|
383
|
+
await selectRow(wheels, 0, value.pickerIndex(control, v, 'native'));
|
|
384
|
+
} else {
|
|
385
|
+
const d = value.toDate(v, 'native');
|
|
386
|
+
value.checkDate(control.props, d, 'native');
|
|
387
|
+
await selectDate(wheels, d);
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
default:
|
|
392
|
+
return viaCallbacks();
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
/** A real drag of the thumb, then the slider's value is read back (a drag lands on a pixel). */
|
|
397
|
+
async adjustSlider(node, control, position) {
|
|
398
|
+
if (bridge.sliderInfo === undefined) throw needsRebuild('sliderInfo', ctx.action);
|
|
399
|
+
const sliderInfo = bridge.sliderInfo;
|
|
400
|
+
const { tag } = await ready(node);
|
|
401
|
+
const range = value.sliderRange(control.props);
|
|
402
|
+
const expected = value.sliderValueAt(range, position);
|
|
403
|
+
const span = range.max - range.min;
|
|
404
|
+
const want = span === 0 ? 0 : (expected - range.min) / span;
|
|
405
|
+
// Native sliders keep their own units (Android: SeekBar progress steps): compare positions.
|
|
406
|
+
// A finger lands on a point, not a value: within one step (1% of a continuous slider) of the
|
|
407
|
+
// position. setValue sets an exact value.
|
|
408
|
+
const tolerance = span > 0 && range.step > 0 ? range.step / span + 1e-6 : 0.01;
|
|
409
|
+
const ratio = (i: { value?: number; minimum?: number; maximum?: number }): number | null =>
|
|
410
|
+
i.value === undefined || i.minimum === undefined || i.maximum === undefined || i.maximum === i.minimum ? null : (i.value - i.minimum) / (i.maximum - i.minimum);
|
|
411
|
+
let info = await sliderInfo(tag);
|
|
412
|
+
const valid = (i: typeof info): i is Required<typeof info> =>
|
|
413
|
+
i.found && i.minX !== undefined && i.maxX !== undefined && i.thumbX !== undefined && i.y !== undefined;
|
|
414
|
+
/** Drag the thumb straight to window x `x`, then read until the native value stops changing. */
|
|
415
|
+
const dragTo = async (x: number): Promise<void> => {
|
|
416
|
+
if (!valid(info)) return;
|
|
417
|
+
const from = { x: info.thumbX, y: info.y };
|
|
418
|
+
const to = { x, y: info.y };
|
|
419
|
+
const moveMs = 150 + Math.abs(to.x - from.x) * 2;
|
|
420
|
+
await gesture(tag, [
|
|
421
|
+
[from, 0],
|
|
422
|
+
[from, SLIDER_PRESS_MS],
|
|
423
|
+
[to, SLIDER_PRESS_MS + moveMs],
|
|
424
|
+
[to, SLIDER_PRESS_MS + moveMs + DROP_HOLD_MS],
|
|
425
|
+
]);
|
|
426
|
+
let prev: number | undefined;
|
|
427
|
+
for (;;) {
|
|
428
|
+
info = await sliderInfo(tag);
|
|
429
|
+
if (info.value === prev || clock.now() >= ctx.deadline) break;
|
|
430
|
+
prev = info.value;
|
|
431
|
+
await sleep(STABLE_POLL_MS * 2);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
// The thumb moves with the finger, minus the touch slop at the start of a drag (measured on iOS
|
|
435
|
+
// 27: ~9 pt; a drag that reverses loses much more). So every drag goes one way, at least
|
|
436
|
+
// SLIDER_RUN_UP long (a separate drag away from the target first when it is closer), and the slop
|
|
437
|
+
// read back after a drag is added to the next one.
|
|
438
|
+
let slop = 0;
|
|
439
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
440
|
+
if (!valid(info)) {
|
|
441
|
+
throw new QuiescoError('ACTION_NOT_SUPPORTED', `${describe(node)} has no native slider to drag`, { action: 'adjustSlider', platform: 'native', hint: 'pass { mode: "js" }' });
|
|
442
|
+
}
|
|
443
|
+
const r0 = ratio(info);
|
|
444
|
+
if (attempt > 0 && r0 !== null && Math.abs(r0 - want) <= tolerance) return;
|
|
445
|
+
const target = info.minX + position * (info.maxX - info.minX);
|
|
446
|
+
if (Math.abs(target - info.thumbX) < SLIDER_RUN_UP) {
|
|
447
|
+
let away = target >= info.thumbX ? -1 : 1;
|
|
448
|
+
const out = target + away * SLIDER_RUN_UP * 1.5;
|
|
449
|
+
if (out < info.minX || out > info.maxX) away = -away;
|
|
450
|
+
await dragTo(target + away * SLIDER_RUN_UP * 1.5);
|
|
451
|
+
if (!valid(info)) continue;
|
|
452
|
+
}
|
|
453
|
+
const dir = Math.sign(target - info.thumbX) || 1;
|
|
454
|
+
await dragTo(target + dir * slop);
|
|
455
|
+
const r = ratio(info);
|
|
456
|
+
if (r !== null && valid(info)) slop += (want - r) * (info.maxX - info.minX) * dir;
|
|
457
|
+
}
|
|
458
|
+
const r = ratio(info);
|
|
459
|
+
if (r !== null && Math.abs(r - want) <= tolerance) return;
|
|
460
|
+
throw new QuiescoError('ACTION_NOT_SUPPORTED', `adjustSlider(${position}) left the slider at ${r === null ? '?' : r.toFixed(3)} of its range (wanted ${want.toFixed(3)})`, {
|
|
461
|
+
action: 'adjustSlider',
|
|
462
|
+
platform: 'native',
|
|
463
|
+
hint: 'the thumb did not follow the drag; check that nothing covers the slider, or pass { mode: "js" }',
|
|
464
|
+
});
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
/** As a screen reader does (iOS: increment / decrement / custom actions; Android: performAccessibilityAction). */
|
|
468
|
+
async accessibilityAction(target, name) {
|
|
469
|
+
const declared = value.declaredAction(target, name, 'native');
|
|
470
|
+
const tag = target.host?.nativeTag() ?? null;
|
|
471
|
+
if (tag === null) return value.jsAccessibilityAction(target, name, 'native');
|
|
472
|
+
if (bridge.accessibilityAction === undefined) throw needsRebuild('accessibilityAction', ctx.action);
|
|
473
|
+
// The native "performed" flag is not reliable (Android's RN delegate dispatches increment /
|
|
474
|
+
// decrement and then returns View's default, false), so the handler is checked here instead.
|
|
475
|
+
if (target.handler === null) {
|
|
476
|
+
throw value.unsupported('accessibilityAction', 'the element declares accessibilityActions but has no onAccessibilityAction', 'native');
|
|
477
|
+
}
|
|
478
|
+
await bridge.accessibilityAction(tag, name, declared.label, declared.index, declared.customIndex);
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/** Gestures one after another (fingers up in between) in the window of `tag`. */
|
|
483
|
+
async function playFingers(tag: number, gestures: FingerPath[][]): Promise<void> {
|
|
484
|
+
if (bridge.multiGesture === undefined) throw needsRebuild('multiGesture', ctx.action);
|
|
485
|
+
const fingers = Math.max(...gestures.map((g) => g.length));
|
|
486
|
+
await bridge.multiGesture(tag, fingers, framesForSequence(gestures));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function wheelContext(tag: number): WheelContext {
|
|
490
|
+
const wheelInfo = bridge.wheelInfo as NonNullable<NativeBridge['wheelInfo']>;
|
|
491
|
+
return {
|
|
492
|
+
read: () => wheelInfo(tag),
|
|
493
|
+
gesture: (path) => gesture(tag, path),
|
|
494
|
+
clock,
|
|
495
|
+
deadline: ctx.deadline,
|
|
496
|
+
viewportHeight: ctx.viewport()?.height ?? null,
|
|
497
|
+
platform: 'native',
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How actions reach the app. `js` calls component callbacks (actions/js.ts); `native` injects real
|
|
3
|
+
* touches and IME text through @quiesco/native (actions/native.ts). The runtime resolves the target
|
|
4
|
+
* element and syncs; the performer only performs.
|
|
5
|
+
*/
|
|
6
|
+
import type { ControlValue, Direction, HostInput, KeyName, Point, Speed } from '@quiesco/protocol';
|
|
7
|
+
|
|
8
|
+
import type { HostNode } from '../fiber-adapter';
|
|
9
|
+
import * as js from './js';
|
|
10
|
+
import type { FingerPath } from './multitouch';
|
|
11
|
+
import type { A11yTarget, Control } from './value';
|
|
12
|
+
import * as value from './value';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Each method performs the action, or returns the HostInput the host must inject (`host` performer:
|
|
16
|
+
* real browser input through Playwright). The runtime then hands it to the host and does not sync.
|
|
17
|
+
*/
|
|
18
|
+
export type Performed = Promise<HostInput | void>;
|
|
19
|
+
|
|
20
|
+
export interface ActionPerformer {
|
|
21
|
+
/** Whether results are HostInputs (the host injects; multi-step actions cannot loop in the agent). */
|
|
22
|
+
readonly viaHost: boolean;
|
|
23
|
+
/** `point`: inside the element, in its own coordinates (default: center). */
|
|
24
|
+
tap(node: HostNode, point?: Point): Performed;
|
|
25
|
+
longPress(node: HostNode, durationMs?: number, point?: Point): Performed;
|
|
26
|
+
multiTap(node: HostNode, count: number): Performed;
|
|
27
|
+
type(node: HostNode, text: string): Performed;
|
|
28
|
+
replaceText(node: HostNode, text: string): Performed;
|
|
29
|
+
clear(node: HostNode): Performed;
|
|
30
|
+
pressKey(node: HostNode, key: KeyName): Performed;
|
|
31
|
+
/** `down` reveals content further down. `from`: start inside the element, normalized 0–1. */
|
|
32
|
+
scroll(node: HostNode, direction: Direction, distance?: number, from?: Point): Performed;
|
|
33
|
+
/** Finger direction: `up` moves the finger up. */
|
|
34
|
+
swipe(node: HostNode, direction: Direction, speed?: Speed, distance?: number, from?: Point): Performed;
|
|
35
|
+
/** Press the element's center, hold `holdMs`, move to the window point `to` over `durationMs`, release. */
|
|
36
|
+
drag(node: HostNode, to: Point, holdMs: number, durationMs: number): Performed;
|
|
37
|
+
/** A touch at a window point, not tied to an element (Detox `device.tap`). */
|
|
38
|
+
tapAt(point: Point, holdMs?: number): Performed;
|
|
39
|
+
/** Set a value control (actions/value.ts) to `value`. */
|
|
40
|
+
setValue(control: Control, value: ControlValue): Performed;
|
|
41
|
+
/** Move the slider `control` (on `node`) to `position` (0 … 1). */
|
|
42
|
+
adjustSlider(node: HostNode, control: Control, position: number): Performed;
|
|
43
|
+
/** Perform a declared accessibility action. */
|
|
44
|
+
accessibilityAction(target: A11yTarget, name: string): Performed;
|
|
45
|
+
/**
|
|
46
|
+
* Two fingers pinch on the element: distance × `scale`, on a line at `angle` radians, after a lead-in
|
|
47
|
+
* of `slop` (the recognizer's activation distance, AgentConfig.gestures).
|
|
48
|
+
*/
|
|
49
|
+
pinch(node: HostNode, scale: number, angle: number, speed: Speed, slop: number): Performed;
|
|
50
|
+
/** Two fingers turn `radians` (clockwise positive) around the element's center, plus `slop` radians first. */
|
|
51
|
+
rotate(node: HostNode, radians: number, speed: Speed, slop: number): Performed;
|
|
52
|
+
/** Fingers along timed window paths; `node` (when given) must be ready for touches first. */
|
|
53
|
+
multiTouch(node: HostNode | null, fingers: FingerPath[]): Performed;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const MULTI_FINGER_HINT = 'multi-finger gestures need real touches (gesture recognizers); use native mode';
|
|
57
|
+
|
|
58
|
+
export function jsPerformer(ctx: js.JsActionContext): ActionPerformer {
|
|
59
|
+
return {
|
|
60
|
+
viaHost: false,
|
|
61
|
+
tap: async (n, point) => js.tap(n, ctx, point),
|
|
62
|
+
longPress: async (n, _durationMs, point) => js.longPress(n, ctx, point),
|
|
63
|
+
multiTap: async (n, count) => js.multiTap(n, count, ctx),
|
|
64
|
+
type: async (n, text) => js.typeText(n, text, ctx),
|
|
65
|
+
replaceText: async (n, text) => js.replaceText(n, text, ctx),
|
|
66
|
+
clear: async (n) => js.clearText(n, ctx),
|
|
67
|
+
pressKey: async (n, key) => js.pressKey(n, key, ctx),
|
|
68
|
+
scroll: async (n, direction, distance) => js.scrollBy(js.requireScrollable(n, 'scroll', ctx), direction, distance),
|
|
69
|
+
swipe: async (n, direction, _speed, distance) => js.scrollBy(js.requireScrollable(n, 'swipe', ctx), js.swipeToScrollDirection(direction), distance),
|
|
70
|
+
drag: async () => {
|
|
71
|
+
throw js.notSupported('drag', ctx, 'dragging needs real touches (gesture recognizers, PanResponder); use native mode');
|
|
72
|
+
},
|
|
73
|
+
tapAt: async (point, holdMs) => js.tapAt(point, holdMs !== undefined && holdMs >= js.LONG_PRESS_THRESHOLD_MS, ctx),
|
|
74
|
+
setValue: async (control, v) => value.jsSetValue(control, v, ctx.platform),
|
|
75
|
+
adjustSlider: async (_node, control, position) => value.jsSetValue(control, value.sliderValueAt(value.sliderRange(control.props), position), ctx.platform),
|
|
76
|
+
accessibilityAction: async (target, name) => value.jsAccessibilityAction(target, name, ctx.platform),
|
|
77
|
+
pinch: async () => {
|
|
78
|
+
throw js.notSupported('pinch', ctx, MULTI_FINGER_HINT);
|
|
79
|
+
},
|
|
80
|
+
rotate: async () => {
|
|
81
|
+
throw js.notSupported('rotate', ctx, MULTI_FINGER_HINT);
|
|
82
|
+
},
|
|
83
|
+
multiTouch: async () => {
|
|
84
|
+
throw js.notSupported('multiTouch', ctx, MULTI_FINGER_HINT);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|