@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -2
  3. package/metro.cjs +144 -0
  4. package/package.json +54 -3
  5. package/src/actions/hit-test.ts +42 -0
  6. package/src/actions/host.ts +194 -0
  7. package/src/actions/js.ts +245 -0
  8. package/src/actions/multitouch.ts +179 -0
  9. package/src/actions/native.ts +500 -0
  10. package/src/actions/performer.ts +87 -0
  11. package/src/actions/value.ts +460 -0
  12. package/src/actions/wheel.ts +260 -0
  13. package/src/api-types.ts +32 -0
  14. package/src/api.ts +11 -0
  15. package/src/assertions/index.ts +160 -0
  16. package/src/backdoor.ts +54 -0
  17. package/src/build-config.ts +6 -0
  18. package/src/connection/client.ts +196 -0
  19. package/src/connection/params.ts +63 -0
  20. package/src/early.ts +10 -0
  21. package/src/errors.ts +23 -0
  22. package/src/events.ts +136 -0
  23. package/src/expo-fetch.js +13 -0
  24. package/src/fiber-adapter/default-deps.native.ts +36 -0
  25. package/src/fiber-adapter/default-deps.ts +38 -0
  26. package/src/fiber-adapter/fiber.ts +81 -0
  27. package/src/fiber-adapter/hook.ts +154 -0
  28. package/src/fiber-adapter/host.ts +210 -0
  29. package/src/fiber-adapter/index.ts +221 -0
  30. package/src/fiber-adapter/scheduler.ts +70 -0
  31. package/src/fiber-adapter/traverse.ts +260 -0
  32. package/src/fiber-adapter/types.ts +142 -0
  33. package/src/index.ts +7 -0
  34. package/src/install.ts +6 -0
  35. package/src/json-check.ts +43 -0
  36. package/src/platform.ts +155 -0
  37. package/src/query/index.ts +5 -0
  38. package/src/query/matcher.ts +121 -0
  39. package/src/query/semantic.ts +266 -0
  40. package/src/query/tree.ts +116 -0
  41. package/src/query/visibility.ts +60 -0
  42. package/src/reset/index.ts +59 -0
  43. package/src/reset/storage-defaults.native.ts +26 -0
  44. package/src/reset/storage-defaults.ts +39 -0
  45. package/src/root/QuiescoRoot.tsx +28 -0
  46. package/src/runtime.ts +1003 -0
  47. package/src/singleton.ts +18 -0
  48. package/src/stub.ts +28 -0
  49. package/src/sync/frame-stable.ts +142 -0
  50. package/src/sync/idle-callback.ts +142 -0
  51. package/src/sync/index.ts +17 -0
  52. package/src/sync/native-settle.ts +41 -0
  53. package/src/sync/network.ts +299 -0
  54. package/src/sync/offline.ts +121 -0
  55. package/src/sync/react.ts +28 -0
  56. package/src/sync/timer.ts +124 -0
  57. package/src/sync/tracker.ts +51 -0
  58. package/src/sync/wait-for-idle.ts +141 -0
  59. package/src/version.ts +2 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,1003 @@
1
+ /**
2
+ * The agent runtime: owns the fiber adapter, the trackers, the connection and the request handlers.
3
+ * One instance per JS context, created when `@quiesco/agent` is first imported (it must be the first
4
+ * import of the app entry, so the DevTools hook and scheduler patch precede the renderer).
5
+ */
6
+ import { SyncDefaults, WEBVIEW_HOOK } from '@quiesco/protocol';
7
+ import type { Direction, Edge, Point, Speed } from '@quiesco/protocol';
8
+ import type {
9
+ ActionSpec,
10
+ ActionType,
11
+ HostInput,
12
+ AgentConfig,
13
+ AgentMethods,
14
+ AssertionSpec,
15
+ ElementSummary,
16
+ JsonValue,
17
+ Matcher,
18
+ ParamsOf,
19
+ Rect,
20
+ ResultOf,
21
+ SyncOption,
22
+ } from '@quiesco/protocol';
23
+
24
+ import * as js from './actions/js';
25
+ import { fromNativeError, nativePerformer } from './actions/native';
26
+ import { NativeSettleTracker } from './sync/native-settle';
27
+ import type { NativeBridge } from './actions/native';
28
+ import { hostPerformer } from './actions/host';
29
+ import { jsPerformer } from './actions/performer';
30
+ import type { ActionPerformer } from './actions/performer';
31
+ import * as value from './actions/value';
32
+ import type { StorageAdapter } from './api-types';
33
+ import { assertionMessage, evaluate } from './assertions';
34
+ import { BackdoorRegistry } from './backdoor';
35
+ import { buildConfig } from './build-config';
36
+ import { AgentClient, MethodNotFound } from './connection/client';
37
+ import { decodeLaunchArgs, resolveParams } from './connection/params';
38
+ import { QuiescoError } from './errors';
39
+ import { EventForwarder } from './events';
40
+ import { FiberAdapter } from './fiber-adapter';
41
+ import type { CompositeNode, FiberAdapterOptions, HostNode } from './fiber-adapter';
42
+ import type { AppDeps } from './platform';
43
+ import { defaultAppDeps } from './platform';
44
+ import { ResetManager } from './reset';
45
+ import { defaultStorageAdapters } from './reset/storage-defaults';
46
+ import { describeMatcher, findAll, getTree, isVisible, structureLines, summarize, toRegExp } from './query';
47
+ import type { Clock, FetchFn, IdleTracker, TimerGlobals } from './sync';
48
+ import { FrameStableTracker, IdleCallbackTracker, NetworkTracker, OfflineGate, ReactTracker, SyncEngine, TimerTracker, ownConnection, realClock } from './sync';
49
+ import { AGENT_VERSION } from './version';
50
+
51
+ /** drag: hold before moving (long enough to start long-press-activated drags), and move duration. */
52
+ const DRAG_HOLD_MS = 800;
53
+ const DRAG_MS: Record<Speed, number> = { slow: 1200, normal: 600, fast: 250 };
54
+ const LONG_PRESS_AT_MS = 800;
55
+ /** scrollToEdge: at most this many steps; each waits for idle (up to the call timeout), so virtualized lists can grow. */
56
+ const MAX_EDGE_STEPS = 60;
57
+ /** Finger direction of a fling towards an edge (to reach the bottom, the finger moves up). */
58
+ const EDGE_FINGER: Record<Edge, Direction> = { top: 'down', bottom: 'up', left: 'right', right: 'left' };
59
+ /**
60
+ * In the background no frames are drawn and Android pauses React Native's timers, so these trackers
61
+ * would never settle (Phase 6.5 spike); they count as idle until the app is active again.
62
+ */
63
+ const BACKGROUND_SKIPPED: ReadonlySet<string> = new Set(['frame-stable', 'timer', 'native']);
64
+ const NONE: ReadonlySet<string> = new Set();
65
+ /** Calls whose results report the time spent waiting for idle (`syncMs`, Phase 6.9 timeline). */
66
+ const TIMED: ReadonlySet<string> = new Set(['query', 'action', 'assert', 'webview', 'scrollView']);
67
+
68
+ export interface RuntimeOptions {
69
+ adapter?: FiberAdapterOptions;
70
+ deps?: AppDeps;
71
+ clock?: Clock;
72
+ globalObject?: Record<string, unknown>;
73
+ /** Trackers to use instead of the default set (tests). */
74
+ trackers?: (runtime: AgentRuntime) => IdleTracker[];
75
+ /** Built-in storage adapters (default: AsyncStorage on native, web storage on web). */
76
+ storage?: () => StorageAdapter[];
77
+ }
78
+
79
+ type Handlers = { [M in keyof AgentMethods]: (params: ParamsOf<AgentMethods[M]>) => Promise<ResultOf<AgentMethods[M]>> };
80
+
81
+ export class AgentRuntime {
82
+ readonly adapter: FiberAdapter;
83
+ readonly engine: SyncEngine;
84
+ readonly events: EventForwarder;
85
+ readonly backdoors = new BackdoorRegistry();
86
+ readonly resets: ResetManager;
87
+ client: AgentClient | null = null;
88
+ config: AgentConfig = {
89
+ sync: {
90
+ timeoutMs: SyncDefaults.timeoutMs,
91
+ timerThresholdMs: SyncDefaults.timerThresholdMs,
92
+ countIntervals: SyncDefaults.countIntervals,
93
+ stableFrames: SyncDefaults.stableFrames,
94
+ ignoreUrls: [],
95
+ trackers: {},
96
+ },
97
+ logLevel: 'warn',
98
+ };
99
+ private readonly deps: AppDeps;
100
+ /** Whether the host has sent its config (hello / configure). */
101
+ private configured = false;
102
+ /** `app.device.setNetwork('offline')` (Phase 6.6). */
103
+ private readonly offline: OfflineGate;
104
+ private readonly clock: Clock;
105
+ private readonly global: Record<string, unknown>;
106
+ private network: NetworkTracker | null = null;
107
+ private timer: TimerTracker | null = null;
108
+ private frames: FrameStableTracker | null = null;
109
+ private idleCallbacks: IdleCallbackTracker | null = null;
110
+ private mounted = 0;
111
+ private remount: (() => void) | null = null;
112
+ private native: ReturnType<AppDeps['nativeModule']> | undefined;
113
+ private launchArgsCache: Record<string, JsonValue> | null = null;
114
+ /** Pending stop after the last QuiescoRoot unmounted (see unmount). */
115
+ private stopTimer: unknown = null;
116
+ /** WebViews (by view tag) whose later page loads already run WEBVIEW_HOOK at document start. */
117
+ private readonly hookedWebViews = new Set<number>();
118
+ /** Original console methods, captured before the event forwarder wraps them. */
119
+ private readonly log: (message: string) => void;
120
+
121
+ constructor(options: RuntimeOptions = {}) {
122
+ this.global = options.globalObject ?? (globalThis as unknown as Record<string, unknown>);
123
+ this.deps = options.deps ?? defaultAppDeps;
124
+ this.clock = options.clock ?? realClock;
125
+ this.adapter = new FiberAdapter({ target: this.global, ...options.adapter });
126
+ this.offline = new OfflineGate(this.global);
127
+ const c = this.global['console'] as { warn?: (...a: unknown[]) => void } | undefined;
128
+ const warn = c?.warn;
129
+ this.log = (m) => warn?.call(c, `[quiesco] ${m}`);
130
+ this.engine = new SyncEngine(options.trackers?.(this) ?? this.defaultTrackers(), this.clock);
131
+ this.engine.setSkipped(() => (this.deps.appState() === 'background' ? BACKGROUND_SKIPPED : NONE));
132
+ this.engine.setBackground(() => this.deps.appState() === 'background', this.deps.onAppStateChange?.bind(this.deps));
133
+ this.resets = new ResetManager(options.storage ?? (() => defaultStorageAdapters()));
134
+ this.events = new EventForwarder({ notify: (e, p) => this.client?.notify(e, p), target: this.global });
135
+ }
136
+
137
+ /** Called at import time: everything that must precede the renderer and the app's first effects. */
138
+ installEarly(): void {
139
+ this.adapter.install();
140
+ // Before the network tracker, which then wraps it (and reports the URLs the app asked for).
141
+ this.offline.install();
142
+ this.engine.install();
143
+ this.events.install();
144
+ }
145
+
146
+ /** QuiescoRoot mounted: restrict queries to its subtree, report setup problems, connect. */
147
+ mount(scopeComponent: unknown): void {
148
+ this.mounted++;
149
+ if (this.stopTimer !== null) {
150
+ // Remounted in the same commit (see unmount): keep the connection.
151
+ this.clock.clearTimeout(this.stopTimer);
152
+ this.stopTimer = null;
153
+ }
154
+ this.adapter.setScope(scopeComponent);
155
+ const problems = [...this.adapter.diagnostics().problems];
156
+ const late = this.adapter.checkRendererSeen();
157
+ if (late !== null) problems.push(late);
158
+ for (const p of problems) this.log(p);
159
+ const im = this.deps.interactionManager();
160
+ if (im !== null) this.idleCallbacks?.attachInteractionManager(im);
161
+ if (this.client === null) {
162
+ const native = this.nativeModule();
163
+ const launch = native?.getLaunchParams() ?? null;
164
+ const params = resolveParams({
165
+ native: launch === null ? null : { ...(launch.port !== null ? { port: launch.port } : {}), ...(launch.session !== null ? { session: launch.session } : {}) },
166
+ global: this.global,
167
+ build: buildConfig,
168
+ });
169
+ this.offline.setExempt(ownConnection(params.port));
170
+ this.network?.setExempt(ownConnection(params.port));
171
+ this.client = new AgentClient({
172
+ params,
173
+ hello: { platform: this.deps.platform(), agentVersion: AGENT_VERSION, nativeModule: native !== null },
174
+ createSocket: (url) => this.deps.createSocket(url),
175
+ handle: (method, p) => this.handle(method, p),
176
+ onReady: (config) => this.configure(config),
177
+ clock: this.clock,
178
+ log: this.log,
179
+ });
180
+ }
181
+ this.client.start();
182
+ }
183
+
184
+ private nativeModule(): ReturnType<AppDeps['nativeModule']> {
185
+ if (this.native === undefined) this.native = this.deps.nativeModule();
186
+ return this.native;
187
+ }
188
+
189
+ /** The parallel worker index the host sent with its config (Phase 6.12); null before the host connected. */
190
+ workerIndex(): number | null {
191
+ return this.configured ? (this.config.workerIndex ?? 0) : null;
192
+ }
193
+
194
+ /**
195
+ * The test launch arguments (`app.device.relaunch({ args })` / config `launchArgs`), read once per JS
196
+ * context. JS-only native builds cannot read them: `{}` with a warning.
197
+ */
198
+ launchArgs(): Record<string, JsonValue> {
199
+ if (this.launchArgsCache !== null) return this.launchArgsCache;
200
+ const native = this.nativeModule();
201
+ const atob = this.global['atob'] as ((s: string) => string) | undefined;
202
+ const { args, problem } = decodeLaunchArgs(native?.getLaunchParams().args, this.global, (b) => {
203
+ if (atob === undefined) throw new Error('atob is not available');
204
+ return atob(b);
205
+ });
206
+ if (problem !== null) this.log(problem);
207
+ else if (native === null && this.deps.platform() !== 'web') this.log('launchArgs(): @quiesco/native is not in the app, so launch arguments cannot be read (returning {})');
208
+ this.launchArgsCache = args;
209
+ return args;
210
+ }
211
+
212
+ /** Registered by QuiescoRoot; changes its key so the whole app remounts (soft reset). */
213
+ setRemount(fn: () => void): () => void {
214
+ this.remount = fn;
215
+ return () => {
216
+ if (this.remount === fn) this.remount = null;
217
+ };
218
+ }
219
+
220
+ /**
221
+ * The connection stops one tick after the last QuiescoRoot unmounts. A root that is unmounted and
222
+ * mounted again in the same commit (expo-router remounts the root layout after a soft reset on iOS;
223
+ * navigation or theme libraries re-keying the tree do the same) keeps its connection, and with it
224
+ * the in-flight request (e.g. the reset) that caused the remount.
225
+ */
226
+ unmount(): void {
227
+ if (--this.mounted > 0) return;
228
+ if (this.stopTimer !== null) this.clock.clearTimeout(this.stopTimer);
229
+ this.stopTimer = this.clock.setTimeout(() => {
230
+ this.stopTimer = null;
231
+ if (this.mounted <= 0) this.client?.stop();
232
+ }, 0);
233
+ }
234
+
235
+ /**
236
+ * A fetch function that the offline mode and the network tracker see like the global one: `expo/fetch`
237
+ * imported directly (src/expo-fetch.js, swapped in by withQuiesco()).
238
+ */
239
+ wrapFetch(original: FetchFn): FetchFn {
240
+ const blocked = this.offline.wrapFetch(original);
241
+ return this.network?.wrapFetch(blocked) ?? blocked;
242
+ }
243
+
244
+ configure(config: AgentConfig): void {
245
+ this.config = config;
246
+ this.configured = true;
247
+ this.offline.setOffline(config.network?.offline === true);
248
+ this.network?.setIgnoreUrls(config.sync.ignoreUrls.map(toRegExp));
249
+ this.timer?.configure({ thresholdMs: config.sync.timerThresholdMs, countIntervals: config.sync.countIntervals });
250
+ this.frames?.setFrames(config.sync.stableFrames);
251
+ this.engine.setEnabled(config.sync.trackers);
252
+ this.events.setLevel(config.logLevel);
253
+ }
254
+
255
+ /** Elements matching `sync.ignoreElements`, left out of the frame-stable snapshot with their subtrees. */
256
+ private ignoredForFrames(roots: readonly HostNode[]): ReadonlySet<HostNode> | undefined {
257
+ const matchers = this.config.sync.ignoreElements ?? [];
258
+ if (matchers.length === 0) return undefined;
259
+ const out = new Set<HostNode>();
260
+ for (const m of matchers) {
261
+ try {
262
+ for (const n of findAll(roots, m)) out.add(n);
263
+ } catch {
264
+ // e.g. a `within` that matches nothing on this screen: nothing to ignore
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+
270
+ handle(method: string, params: unknown): Promise<unknown> {
271
+ const h = (this.handlers as Record<string, ((p: unknown) => Promise<unknown>) | undefined>)[method];
272
+ if (h === undefined) return Promise.reject(new MethodNotFound(method));
273
+ if (!TIMED.has(method)) return h(params ?? {});
274
+ // Phase 6.9 timeline: how much of the call was spent waiting for idle (calls run one at a time).
275
+ this.syncSpent = 0;
276
+ return h(params ?? {}).then((r) => (typeof r === 'object' && r !== null ? { ...r, syncMs: Math.round(this.syncSpent) } : r));
277
+ }
278
+
279
+ /** Time spent in syncIfNeeded during the current timed call. */
280
+ private syncSpent = 0;
281
+
282
+ private readonly handlers: Partial<Handlers> = {
283
+ query: async (p) => {
284
+ await this.syncIfNeeded(p);
285
+ const roots = this.adapter.getHostRoots();
286
+ const viewport = this.adapter.viewport();
287
+ return { elements: findAll(roots, p.matcher).map((n) => summarize(n, viewport)) };
288
+ },
289
+ waitForIdle: async (p) => this.engine.waitForIdle({ timeoutMs: p.timeoutMs ?? this.config.sync.timeoutMs }),
290
+ getIdleState: async () => ({ trackers: this.engine.getIdleState() }),
291
+ getTree: async () => ({ root: getTree(this.adapter.getHostRoots()) }),
292
+ action: async (p) => this.action(p.action, p.mode, p),
293
+ assert: async (p) => this.assert(p.assertion, p),
294
+ backdoor: async (p) => {
295
+ const value = await this.backdoors.call(p.name, p.args ?? []);
296
+ await this.engine.waitForIdle({ timeoutMs: this.config.sync.timeoutMs });
297
+ return { value };
298
+ },
299
+ configure: async (p) => {
300
+ this.configure(p.config);
301
+ return {};
302
+ },
303
+ deviceInfo: async () => {
304
+ const w = this.deps.windowInfo();
305
+ return { window: { width: w.width, height: w.height }, pixelRatio: w.pixelRatio, appState: this.deps.appState(), ...this.deps.settings() };
306
+ },
307
+ setOrientation: async (p) => {
308
+ const rotate = this.nativeModule()?.setOrientation;
309
+ if (rotate === undefined) return { supported: false };
310
+ const error = await rotate(p.orientation === 'landscape');
311
+ return error === '' ? { supported: true } : { supported: true, error };
312
+ },
313
+ getClipboard: async () => {
314
+ const get = this.nativeModule()?.getClipboard;
315
+ if (get === undefined) return { supported: false };
316
+ return { supported: true, text: await get() };
317
+ },
318
+ setClipboard: async (p) => {
319
+ const set = this.nativeModule()?.setClipboard;
320
+ if (set === undefined) return { supported: false };
321
+ await set(p.text);
322
+ return { supported: true };
323
+ },
324
+ // No wait for idle: it is also called while the app is in the background.
325
+ postNotification: async (p) => {
326
+ const post = this.nativeModule()?.postNotification;
327
+ if (post === undefined) return { supported: false };
328
+ return { supported: true, enabled: (await post(p.title, p.body, p.data)).enabled };
329
+ },
330
+ suspend: async () => {
331
+ const suspend = this.nativeModule()?.suspend;
332
+ if (suspend === undefined) return { suspended: false };
333
+ return { suspended: await suspend() };
334
+ },
335
+ webview: async (p) => this.webview(p.matcher, p.script, p),
336
+ scrollView: async (p) => this.scrollView(p.matcher, p.to, p, p.indicators),
337
+ reset: async () => {
338
+ const start = this.clock.now();
339
+ await this.resets.run(this.remount);
340
+ await this.engine.waitForIdle({ timeoutMs: this.config.sync.timeoutMs });
341
+ return { durationMs: this.clock.now() - start };
342
+ },
343
+ };
344
+
345
+ private timeout(opts: SyncOption): number {
346
+ return opts.timeoutMs ?? this.config.sync.timeoutMs;
347
+ }
348
+
349
+ private async syncIfNeeded(opts: SyncOption, timeoutMs = this.timeout(opts)): Promise<void> {
350
+ if (opts.sync === false) return;
351
+ const start = this.clock.now();
352
+ try {
353
+ await this.engine.waitForIdle({ timeoutMs, reportedTimeoutMs: this.timeout(opts) });
354
+ } finally {
355
+ this.syncSpent += this.clock.now() - start;
356
+ }
357
+ }
358
+
359
+ private sleep(ms: number): Promise<void> {
360
+ return new Promise((r) => {
361
+ this.clock.setTimeout(r, ms);
362
+ });
363
+ }
364
+
365
+ private summarize(n: HostNode): ElementSummary {
366
+ return summarize(n, this.adapter.viewport());
367
+ }
368
+
369
+ /**
370
+ * The single visible element an action targets. Waits (up to the timeout) for it to appear and become
371
+ * visible; several matches fail immediately with MULTIPLE_MATCHES.
372
+ */
373
+ private async resolveTarget(matcher: Matcher, opts: SyncOption, deadline: number, requireVisible = true): Promise<HostNode> {
374
+ return (await this.resolveLoop(matcher, opts, deadline, requireVisible, null)) as HostNode;
375
+ }
376
+
377
+ /**
378
+ * Like resolveTarget, but when no host element matches, a component that renders none (Android's
379
+ * DateTimePicker) matched by `id` (its testID) or `type` (its name) is the target (HU 2026-11-09).
380
+ * Such a target has no position: only value actions take it.
381
+ */
382
+ private resolveControlTarget(matcher: Matcher, opts: SyncOption, deadline: number): Promise<HostNode | CompositeNode> {
383
+ return this.resolveLoop(matcher, opts, deadline, true, () => this.hostlessMatches(matcher));
384
+ }
385
+
386
+ /** Components without a host element matching the `id` / `type` / `index` of `matcher` (other fields: none). */
387
+ private hostlessMatches(matcher: Matcher): CompositeNode[] {
388
+ const { id, type, index, ...rest } = matcher;
389
+ if (Object.keys(rest).length > 0 || (id === undefined && type === undefined)) return [];
390
+ const idRe = id === undefined ? null : toRegExp(id);
391
+ const all = this.adapter.findHostlessComposites((c) => {
392
+ if (type !== undefined && c.name !== type) return false;
393
+ if (idRe === null) return true;
394
+ if (c.testID === undefined) return false;
395
+ return typeof idRe === 'string' ? c.testID === idRe : idRe.test(c.testID);
396
+ });
397
+ if (index === undefined) return all;
398
+ const one = all[index];
399
+ return one === undefined ? [] : [one];
400
+ }
401
+
402
+ private async resolveLoop(
403
+ matcher: Matcher,
404
+ opts: SyncOption,
405
+ deadline: number,
406
+ requireVisible: boolean,
407
+ fallback: (() => CompositeNode[]) | null,
408
+ ): Promise<HostNode | CompositeNode> {
409
+ for (;;) {
410
+ let busy: QuiescoError | null = null;
411
+ try {
412
+ await this.syncIfNeeded(opts, Math.max(1, deadline - this.clock.now()));
413
+ } catch (e) {
414
+ // Out of time while waiting for idle. If the target is not there either, that is the real
415
+ // problem: report it (with what was busy) rather than a sync timeout on the last few ms.
416
+ if (!(e instanceof QuiescoError) || e.errorName !== 'SYNC_TIMEOUT') throw e;
417
+ busy = e;
418
+ }
419
+ const found = findAll(this.adapter.getHostRoots(), matcher);
420
+ if (found.length > 1) {
421
+ throw new QuiescoError('MULTIPLE_MATCHES', `${found.length} elements match ${describeMatcher(matcher)}; add \`index\` or a more specific matcher`, {
422
+ matcher,
423
+ matches: found.map((n) => this.summarize(n)),
424
+ });
425
+ }
426
+ const node = found[0];
427
+ if (node === undefined && fallback !== null) {
428
+ const composites = fallback();
429
+ if (composites.length > 1) {
430
+ throw new QuiescoError('MULTIPLE_MATCHES', `${composites.length} components without a view of their own match ${describeMatcher(matcher)}; add \`index\``, { matcher, matches: [] });
431
+ }
432
+ const only = composites[0];
433
+ if (only !== undefined) {
434
+ if (busy !== null) throw busy;
435
+ return only;
436
+ }
437
+ }
438
+ const visible = node !== undefined && (!requireVisible || isVisible(node, this.adapter.viewport()));
439
+ if (busy !== null && visible) throw busy;
440
+ if (visible) return node;
441
+ if (busy !== null || this.clock.now() >= deadline) {
442
+ const why = node === undefined ? 'No element matches' : 'The element exists but is not visible:';
443
+ const alsoBusy = busy === null ? '' : ` (the app was also still busy: ${busy.message})`;
444
+ throw new QuiescoError('ELEMENT_NOT_FOUND', `${why} ${describeMatcher(matcher)}${alsoBusy}`, { matcher });
445
+ }
446
+ await this.sleep(SyncDefaults.retryIntervalMs);
447
+ }
448
+ }
449
+
450
+ /**
451
+ * The target of a touch action. In native mode (real touches) a software keyboard covering the target
452
+ * is dismissed first, then the target is looked up again once the app has settled (HU 2026-11-08):
453
+ * a user would close the keyboard to reach it, and on Android an injected touch would otherwise land
454
+ * on the element behind the keyboard.
455
+ */
456
+ private async resolveActionTarget(matcher: Matcher, mode: ParamsOf<AgentMethods['action']>['mode'], opts: SyncOption, deadline: number): Promise<HostNode> {
457
+ const node = await this.resolveTarget(matcher, opts, deadline);
458
+ if (mode !== 'native') return node;
459
+ const keyboard = this.deps.keyboardFrame();
460
+ const rect = node.measure();
461
+ if (keyboard === null || rect === null || !overlaps(keyboard, rect)) return node;
462
+ this.deps.dismissKeyboard();
463
+ return this.resolveTarget(matcher, opts, deadline);
464
+ }
465
+
466
+ private async action(spec: ActionSpec, mode: ParamsOf<AgentMethods['action']>['mode'], opts: SyncOption): Promise<ResultOf<AgentMethods['action']>> {
467
+ // Device-level actions are performed by the host's DeviceProvider (adb / simctl / Playwright).
468
+ if (spec.type === 'pressBack') return { hostInput: { kind: 'back' } };
469
+ if (spec.type === 'openUrl') return this.openUrl(spec.url, opts);
470
+
471
+ const timeoutMs = this.timeout(opts);
472
+ const deadline = this.clock.now() + timeoutMs;
473
+ let node: HostNode | undefined;
474
+ let hostInput: HostInput | void = undefined;
475
+ const perform = (matcher: Matcher): ActionPerformer => this.performer(mode, spec.type, matcher, deadline);
476
+
477
+ switch (spec.type) {
478
+ case 'dismissKeyboard':
479
+ await this.syncIfNeeded(opts);
480
+ this.deps.dismissKeyboard();
481
+ break;
482
+ case 'tap':
483
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
484
+ hostInput = await perform(spec.matcher).tap(node, this.pointInside(node, spec.point, 'tap'));
485
+ break;
486
+ case 'longPress':
487
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
488
+ hostInput = await perform(spec.matcher).longPress(node, spec.durationMs, this.pointInside(node, spec.point, 'longPress'));
489
+ break;
490
+ case 'doubleTap':
491
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
492
+ hostInput = await perform(spec.matcher).multiTap(node, 2);
493
+ break;
494
+ case 'multiTap':
495
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
496
+ hostInput = await perform(spec.matcher).multiTap(node, spec.count);
497
+ break;
498
+ case 'type':
499
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
500
+ hostInput = await perform(spec.matcher).type(node, spec.text);
501
+ break;
502
+ case 'replaceText':
503
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
504
+ hostInput = await perform(spec.matcher).replaceText(node, spec.text);
505
+ break;
506
+ case 'clear':
507
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
508
+ hostInput = await perform(spec.matcher).clear(node);
509
+ break;
510
+ case 'pressKey':
511
+ // The key goes to the field: it needs to exist, not to be visible or reachable by a touch.
512
+ node = await this.resolveExisting(spec.matcher, opts, deadline);
513
+ hostInput = await perform(spec.matcher).pressKey(node, spec.key);
514
+ break;
515
+ case 'scroll':
516
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
517
+ hostInput = await perform(spec.matcher).scroll(node, spec.direction, spec.distance, spec.from);
518
+ break;
519
+ case 'swipe':
520
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
521
+ hostInput = await perform(spec.matcher).swipe(node, spec.direction, spec.speed, spec.distance, spec.from);
522
+ break;
523
+ case 'scrollTo':
524
+ node = await this.scrollTo(spec.container, spec.target, opts, deadline, this.loopablePerformer(perform(spec.container)));
525
+ break;
526
+ case 'scrollToEdge':
527
+ ({ node, hostInput } = await this.scrollToEdge(spec.matcher, spec.edge, mode, opts, deadline, perform(spec.matcher)));
528
+ break;
529
+ case 'scrollToIndex':
530
+ node = await this.scrollToIndex(spec.matcher, spec.index, spec.viewPosition, opts, deadline);
531
+ break;
532
+ case 'drag': {
533
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
534
+ let to: Point;
535
+ if ('point' in spec.to) {
536
+ to = spec.to.point;
537
+ } else {
538
+ const rect = (await this.resolveTarget(spec.to.matcher, opts, deadline)).measure();
539
+ if (rect === null) throw new QuiescoError('ELEMENT_NOT_FOUND', `The drop target ${describeMatcher(spec.to.matcher)} cannot be measured`, { matcher: spec.to.matcher });
540
+ to = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
541
+ }
542
+ hostInput = await perform(spec.matcher).drag(node, to, spec.holdMs ?? DRAG_HOLD_MS, DRAG_MS[spec.speed ?? 'normal']);
543
+ break;
544
+ }
545
+ case 'setValue': {
546
+ const target = await this.resolveControlTarget(spec.matcher, opts, deadline);
547
+ if ('measure' in target) node = target;
548
+ const control = value.detectControl(target, this.deps.platform());
549
+ hostInput = await perform(spec.matcher).setValue(control, spec.value);
550
+ break;
551
+ }
552
+ case 'adjustSlider': {
553
+ if (!(spec.position >= 0 && spec.position <= 1)) throw value.unsupported('adjustSlider', `position must be between 0 and 1, got ${spec.position}`, this.deps.platform());
554
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
555
+ const control = value.detectControl(node, this.deps.platform());
556
+ if (control.kind !== 'slider') throw value.unsupported('adjustSlider', `${describeMatcher(spec.matcher)} is not a slider (found a ${control.kind})`, this.deps.platform());
557
+ hostInput = await perform(spec.matcher).adjustSlider(node, control, spec.position);
558
+ break;
559
+ }
560
+ case 'accessibilityAction': {
561
+ // A screen reader acts on the element wherever it is: it needs to exist, not to be on screen.
562
+ const target = await this.resolveLoop(spec.matcher, opts, deadline, false, () => this.hostlessMatches(spec.matcher));
563
+ if ('measure' in target) node = target;
564
+ const a11y = value.a11yTarget(target);
565
+ if (a11y === null) {
566
+ throw value.unsupported('accessibilityAction', `${describeMatcher(spec.matcher)} declares no accessibilityActions (nor does an ancestor)`, this.deps.platform());
567
+ }
568
+ hostInput = await perform(spec.matcher).accessibilityAction(a11y, spec.name);
569
+ break;
570
+ }
571
+ case 'pinch':
572
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
573
+ hostInput = await perform(spec.matcher).pinch(node, spec.scale, spec.angle ?? 0, spec.speed ?? 'normal', this.config.gestures?.pinch ?? 0);
574
+ break;
575
+ case 'rotate':
576
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
577
+ hostInput = await perform(spec.matcher).rotate(node, spec.radians, spec.speed ?? 'normal', this.config.gestures?.rotation ?? 0);
578
+ break;
579
+ case 'multiTouch': {
580
+ // Element coordinates when a target is given (it must be visible and reachable), else window coordinates.
581
+ let origin: Point = { x: 0, y: 0 };
582
+ if (spec.matcher !== undefined) {
583
+ node = await this.resolveActionTarget(spec.matcher, mode, opts, deadline);
584
+ const r = node.measure();
585
+ if (r !== null) origin = { x: r.x, y: r.y };
586
+ } else {
587
+ await this.syncIfNeeded(opts, Math.max(1, deadline - this.clock.now()));
588
+ }
589
+ const fingers = spec.fingers.map((f) => f.map((p): [Point, number] => [{ x: origin.x + p.x, y: origin.y + p.y }, p.t]));
590
+ hostInput = await perform(spec.matcher ?? {}).multiTouch(node ?? null, fingers);
591
+ break;
592
+ }
593
+ case 'tapAt':
594
+ case 'longPressAt':
595
+ await this.syncIfNeeded(opts, Math.max(1, deadline - this.clock.now()));
596
+ hostInput = await perform({}).tapAt(spec.point, spec.type === 'longPressAt' ? (spec.durationMs ?? LONG_PRESS_AT_MS) : undefined);
597
+ break;
598
+ }
599
+
600
+ if (hostInput) {
601
+ // The host injects it, then waits for idle itself (Driver).
602
+ return node === undefined ? { hostInput } : { element: this.summarize(node), hostInput };
603
+ }
604
+ // Multi-step scrolls give each step (and the final settle) the full timeout; others share one deadline.
605
+ const multiStep = spec.type === 'scrollToEdge' || spec.type === 'scrollToIndex';
606
+ await this.syncIfNeeded(opts, multiStep ? timeoutMs : Math.max(1, deadline - this.clock.now()));
607
+ return node === undefined ? {} : { element: this.summarize(node) };
608
+ }
609
+
610
+ /** The single element matching `matcher`, visible or not (keys go to a field, not to a point). */
611
+ private resolveExisting(matcher: Matcher, opts: SyncOption, deadline: number): Promise<HostNode> {
612
+ return this.resolveTarget(matcher, opts, deadline, false);
613
+ }
614
+
615
+ /** `point` (element coordinates) checked against the element's size. */
616
+ private pointInside(node: HostNode, point: Point | undefined, action: ActionType): Point | undefined {
617
+ if (point === undefined) return undefined;
618
+ const r = node.measure();
619
+ if (r !== null && (point.x < 0 || point.y < 0 || point.x >= r.width || point.y >= r.height)) {
620
+ throw new QuiescoError('ACTION_NOT_SUPPORTED', `point (${point.x}, ${point.y}) is outside the element (${Math.round(r.width)}×${Math.round(r.height)})`, {
621
+ action,
622
+ platform: this.deps.platform(),
623
+ hint: 'points are in the element\'s own coordinates, from its top-left corner',
624
+ });
625
+ }
626
+ return point;
627
+ }
628
+
629
+ /**
630
+ * Scroll until the content offset stops changing at `edge`. Real flings in native mode; otherwise
631
+ * (js mode, or web where the host injects input and the agent cannot loop) the ScrollView is set to
632
+ * the edge directly. Every step waits for idle, so virtualized lists render more rows and grow.
633
+ */
634
+ private async scrollToEdge(
635
+ matcher: Matcher,
636
+ edge: Edge,
637
+ mode: ParamsOf<AgentMethods['action']>['mode'],
638
+ opts: SyncOption,
639
+ deadline: number,
640
+ performer: ActionPerformer,
641
+ ): Promise<{ node: HostNode; hostInput: HostInput | void }> {
642
+ const node = await this.resolveActionTarget(matcher, mode, opts, deadline);
643
+ const jsCtx = this.jsContext();
644
+ const scrollable = js.requireScrollable(node, 'scroll', jsCtx);
645
+ const programmatic = mode === 'js' || performer.viaHost;
646
+ // Each step gets the full timeout to settle (a long list takes many flings); MAX_EDGE_STEPS bounds the total.
647
+ for (let step = 0; step < MAX_EDGE_STEPS; step++) {
648
+ const before = scrollable.offset();
649
+ if (programmatic) js.scrollToEdge(scrollable, edge);
650
+ // Real input: fling (a fast swipe with momentum), like a user heading for the end.
651
+ else await performer.swipe(findAll(this.adapter.getHostRoots(), matcher)[0] ?? node, EDGE_FINGER[edge], 'fast');
652
+ await this.syncIfNeeded(opts);
653
+ const after = scrollable.offset();
654
+ if (Math.abs(after.x - before.x) < 1 && Math.abs(after.y - before.y) < 1) break;
655
+ }
656
+ return { node, hostInput: undefined };
657
+ }
658
+
659
+ /**
660
+ * Bring row `index` of a FlatList / FlashList into view (all modes: a programmatic scroll, like a
661
+ * user's fling would end). VirtualizedList.scrollToIndex throws (or calls the app's
662
+ * onScrollToIndexFailed) for rows beyond the measured ones when the list has no getItemLayout
663
+ * (findings.md 6.2), so Quiesco first scrolls to the last measured row, waits for the list to render
664
+ * further, and repeats until the row is reachable.
665
+ */
666
+ private async scrollToIndex(matcher: Matcher, index: number, viewPosition: number | undefined, opts: SyncOption, deadline: number): Promise<HostNode> {
667
+ const node = await this.resolveTarget(matcher, opts, deadline);
668
+ const inst = node.findInstance('scrollToIndex');
669
+ if (inst === null) {
670
+ throw new QuiescoError('ACTION_NOT_SUPPORTED', `${describeMatcher(matcher)} is not a FlatList / FlashList (no scrollToIndex)`, {
671
+ action: 'scrollToIndex',
672
+ platform: this.deps.platform(),
673
+ hint: 'match the list itself (its testID) or an element inside it; for other scroll views use scrollTo / scrollToEdge',
674
+ });
675
+ }
676
+ let previous = -1;
677
+ for (;;) {
678
+ const raw = inst.raw();
679
+ const props = (typeof raw['props'] === 'object' && raw['props'] !== null ? raw['props'] : {}) as Record<string, unknown>;
680
+ const count = listLength(props);
681
+ if (count !== null && (index < 0 || index >= count)) {
682
+ throw new QuiescoError('ELEMENT_NOT_FOUND', `Row ${index} is out of range: ${describeMatcher(matcher)} has ${count} rows`, { matcher });
683
+ }
684
+ const highest = highestMeasuredRow(raw);
685
+ if (typeof props['getItemLayout'] === 'function' || highest === null || index <= highest) {
686
+ try {
687
+ inst.call({ index, animated: false, ...(viewPosition === undefined ? {} : { viewPosition }) });
688
+ } catch (e) {
689
+ const message = e instanceof Error ? e.message : String(e);
690
+ // A list whose measurements Quiesco cannot read: scroll a screen further and try again.
691
+ if (highest === null && message.includes('onScrollToIndexFailed') && (await this.scrollOneScreen(node, opts))) continue;
692
+ throw new QuiescoError('ACTION_NOT_SUPPORTED', `scrollToIndex(${index}) failed: ${message}`, { action: 'scrollToIndex', platform: this.deps.platform() });
693
+ }
694
+ await this.syncIfNeeded(opts);
695
+ return node;
696
+ }
697
+ // Each step gets the full timeout; the loop ends when the list stops growing.
698
+ if (highest <= previous) {
699
+ throw new QuiescoError('ELEMENT_NOT_FOUND', `Row ${index} of ${describeMatcher(matcher)} was not rendered (the list stopped at row ${highest})`, { matcher });
700
+ }
701
+ previous = highest;
702
+ inst.call({ index: highest, viewPosition: 0, animated: false });
703
+ await this.syncIfNeeded(opts);
704
+ }
705
+ }
706
+
707
+ /** Scroll the list containing `node` down by a screen and wait for idle; false at the end. */
708
+ private async scrollOneScreen(node: HostNode, opts: SyncOption): Promise<boolean> {
709
+ const s = js.findScrollable(node);
710
+ if (s === null) return false;
711
+ const before = s.offset();
712
+ js.scrollBy(s, 'down');
713
+ await this.syncIfNeeded(opts);
714
+ const after = s.offset();
715
+ return Math.abs(after.y - before.y) >= 1 || Math.abs(after.x - before.x) >= 1;
716
+ }
717
+
718
+ private jsContext(): js.JsActionContext {
719
+ return { platform: this.deps.platform(), roots: () => this.adapter.getHostRoots() };
720
+ }
721
+
722
+ /**
723
+ * Deep links: inside the app when the native module can (iOS, where `simctl openurl` would show a
724
+ * system confirmation alert), otherwise by the host (`am start` on Android, the address bar on web).
725
+ * Independent of the action mode: no touch is involved.
726
+ */
727
+ private async openUrl(url: string, opts: SyncOption): Promise<ResultOf<AgentMethods['action']>> {
728
+ const bridge = this.nativeModule();
729
+ if (bridge?.openUrl === undefined) return { hostInput: { kind: 'navigate', url } };
730
+ const timeoutMs = this.timeout(opts);
731
+ let opened: boolean;
732
+ try {
733
+ ({ opened } = await bridge.openUrl(url));
734
+ } catch (e) {
735
+ throw fromNativeError(e, 'openUrl', 'openUrl', {});
736
+ }
737
+ if (!opened) {
738
+ throw new QuiescoError('ACTION_NOT_SUPPORTED', `The system refused to open ${url} (is its scheme registered by this app?)`, {
739
+ action: 'openUrl',
740
+ platform: this.deps.platform(),
741
+ });
742
+ }
743
+ await this.syncIfNeeded(opts, timeoutMs);
744
+ return {};
745
+ }
746
+
747
+ /**
748
+ * `js`: component callbacks. `native`: real input through @quiesco/native when the app has it,
749
+ * otherwise the host injects real input (web via Playwright); a host that cannot inject a kind of
750
+ * input rejects it with ACTION_NOT_SUPPORTED.
751
+ */
752
+ private performer(mode: ParamsOf<AgentMethods['action']>['mode'], action: ActionType, matcher: Matcher, deadline: number): ActionPerformer {
753
+ if (mode === 'js') return jsPerformer(this.jsContext());
754
+ const roots = (): HostNode[] => this.adapter.getHostRoots();
755
+ const viewport = (): ReturnType<FiberAdapter['viewport']> => this.adapter.viewport();
756
+ const bridge: NativeBridge | null = this.nativeModule();
757
+ if (bridge === null) return hostPerformer({ clock: this.clock, roots, viewport, matcher, deadline });
758
+ return nativePerformer({ bridge, clock: this.clock, roots, viewport, action, matcher, deadline });
759
+ }
760
+
761
+ /** scrollTo loops in the agent; with host-injected input it scrolls with the JS fallback instead. */
762
+ private loopablePerformer(p: ActionPerformer): ActionPerformer {
763
+ return p.viaHost ? jsPerformer(this.jsContext()) : p;
764
+ }
765
+
766
+ /**
767
+ * Scroll `container` until `target` is visible: down until the offset stops changing, then up.
768
+ * Each step waits for idle so lists can render more items.
769
+ */
770
+ private async scrollTo(container: Matcher, target: Matcher, opts: SyncOption, deadline: number, performer: ActionPerformer): Promise<HostNode> {
771
+ const containerNode = await this.resolveTarget(container, opts, deadline);
772
+ // Offset readout (layout-based, works in both modes) to notice the end of the content.
773
+ const scrollable = js.findScrollable(containerNode);
774
+ const MAX_STEPS_WITHOUT_OFFSET = 10;
775
+ for (const direction of ['down', 'up'] as const) {
776
+ for (let step = 0; ; step++) {
777
+ const visible = findAll(this.adapter.getHostRoots(), target).filter((n) => isVisible(n, this.adapter.viewport()));
778
+ if (visible.length === 1) return visible[0] as HostNode;
779
+ if (visible.length > 1) {
780
+ throw new QuiescoError('MULTIPLE_MATCHES', `${visible.length} elements match ${describeMatcher(target)}`, { matcher: target, matches: visible.map((n) => this.summarize(n)) });
781
+ }
782
+ if (this.clock.now() >= deadline || (scrollable === null && step >= MAX_STEPS_WITHOUT_OFFSET)) break;
783
+ const before = scrollable?.offset();
784
+ const current = findAll(this.adapter.getHostRoots(), container)[0] ?? containerNode;
785
+ await performer.scroll(current, direction);
786
+ await this.syncIfNeeded(opts, Math.max(1, deadline - this.clock.now()));
787
+ const after = scrollable?.offset();
788
+ if (before !== undefined && after !== undefined && Math.abs(after.x - before.x) < 1 && Math.abs(after.y - before.y) < 1) break; // reached the end
789
+ }
790
+ }
791
+ throw new QuiescoError('ELEMENT_NOT_FOUND', `Scrolled ${describeMatcher(container)} to both ends without finding a visible ${describeMatcher(target)}`, { matcher: target });
792
+ }
793
+
794
+ /**
795
+ * A WebView (Phase 6.8): react-native-webview's native view (iOS / Android, through @quiesco/native) or
796
+ * an `<iframe>` (web, which the host drives through Playwright's frames). Evaluates `script` in the
797
+ * native page; the page logic itself lives in the host (@quiesco/core webview.ts).
798
+ */
799
+ private async webview(matcher: Matcher | undefined, script: string | undefined, opts: SyncOption): Promise<ResultOf<AgentMethods['webview']>> {
800
+ const deadline = this.clock.now() + this.timeout(opts);
801
+ const node = await this.resolveWebView(matcher, opts, deadline);
802
+ const rect = node.measure();
803
+ const what = matcher === undefined ? 'the WebView' : describeMatcher(matcher);
804
+ if (rect === null) throw new QuiescoError('ELEMENT_NOT_FOUND', `${what} cannot be measured`, { matcher: matcher ?? {} });
805
+ if (this.deps.platform() === 'web') return { rect, viaHost: true };
806
+ const native = this.nativeModule();
807
+ const tag = node.nativeTag();
808
+ if (native?.webviewEval === undefined || native.webviewInfo === undefined || tag === null) {
809
+ throw new QuiescoError('ACTION_NOT_SUPPORTED', `WebViews need @quiesco/native${native === null ? '' : ' built with Phase 6.8 (rebuild the app)'}`, {
810
+ action: 'webview',
811
+ platform: this.deps.platform(),
812
+ hint: 'add @quiesco/native to the app and rebuild it: Quiesco evaluates scripts in the WebView natively',
813
+ });
814
+ }
815
+ if (!this.hookedWebViews.has(tag)) {
816
+ this.hookedWebViews.add(tag);
817
+ await native.webviewAddStartScript?.(tag, WEBVIEW_HOOK).catch(() => false);
818
+ }
819
+ const info = await native.webviewInfo(tag);
820
+ if (!info.found) throw new QuiescoError('ELEMENT_NOT_FOUND', `${what} contains no WebView (react-native-webview)`, { matcher: matcher ?? {} });
821
+ // The web content's own rect: below an inset (iOS content inset), maybe smaller than the host.
822
+ const web: Rect = {
823
+ x: (info.x ?? rect.x) + (info.insetLeft ?? 0),
824
+ y: (info.y ?? rect.y) + (info.insetTop ?? 0),
825
+ width: info.width ?? rect.width,
826
+ height: info.height ?? rect.height,
827
+ };
828
+ if (script === undefined) return { rect: web, loading: info.loading === true };
829
+ let value: string;
830
+ try {
831
+ value = await native.webviewEval(tag, script);
832
+ } catch (e) {
833
+ throw new QuiescoError('ACTION_NOT_SUPPORTED', `evaluating a script in ${what} failed: ${e instanceof Error ? e.message : String(e)}`, { action: 'webview', platform: this.deps.platform() });
834
+ }
835
+ return { rect: web, loading: info.loading === true, value };
836
+ }
837
+
838
+ /**
839
+ * The scroll view containing the element matching `matcher`, or the largest visible one (Phase 6.9
840
+ * full-page screenshots); scrolled to `to` first when given.
841
+ */
842
+ private async scrollView(matcher: Matcher | undefined, to: { x: number; y: number } | undefined, opts: SyncOption, indicators?: boolean): Promise<ResultOf<AgentMethods['scrollView']>> {
843
+ const deadline = this.clock.now() + this.timeout(opts);
844
+ let scrollable: js.Scrollable | null;
845
+ if (matcher !== undefined) {
846
+ const node = await this.resolveTarget(matcher, opts, deadline);
847
+ scrollable = js.findScrollable(node);
848
+ if (scrollable === null) {
849
+ throw new QuiescoError('ACTION_NOT_SUPPORTED', `${describeMatcher(matcher)} is not a scroll view nor inside one`, { action: 'scroll', platform: this.deps.platform() });
850
+ }
851
+ } else {
852
+ await this.syncIfNeeded(opts, Math.max(1, deadline - this.clock.now()));
853
+ const viewport = this.adapter.viewport();
854
+ let best: { s: js.Scrollable; area: number } | null = null;
855
+ // Scroll views clip their content (on the web RN's ScrollView is a plain div, without a 'scroll' type).
856
+ for (const n of findAll(this.adapter.getHostRoots(), {})) {
857
+ if (!n.clipsContent || !isVisible(n, viewport)) continue;
858
+ const s = js.findScrollable(n);
859
+ const r = s?.viewport();
860
+ if (s == null || r == null) continue;
861
+ const area = r.width * r.height;
862
+ if (best === null || area > best.area) best = { s, area };
863
+ }
864
+ if (best === null) throw new QuiescoError('ELEMENT_NOT_FOUND', 'No scroll view on screen (a full-page screenshot needs a ScrollView / FlatList)', { matcher: { type: 'scroll' } });
865
+ scrollable = best.s;
866
+ }
867
+ let indicatorsWere: boolean | undefined;
868
+ if (indicators !== undefined) {
869
+ const tag = scrollable.host()?.nativeTag() ?? null;
870
+ const was = tag === null ? -1 : await (this.nativeModule()?.setScrollIndicators?.(tag, indicators) ?? Promise.resolve(-1)).catch(() => -1);
871
+ if (was >= 0) indicatorsWere = was === 1;
872
+ }
873
+ if (to !== undefined) {
874
+ scrollable.scrollTo(to.x, to.y);
875
+ await this.syncIfNeeded(opts, Math.max(1, deadline - this.clock.now()));
876
+ }
877
+ const rect = scrollable.viewport();
878
+ const content = scrollable.content();
879
+ if (rect === null || content === null) throw new QuiescoError('ELEMENT_NOT_FOUND', 'The scroll view cannot be measured', { matcher: matcher ?? { type: 'scroll' } });
880
+ return { rect, offset: scrollable.offset(), content: { width: content.width, height: content.height }, ...(indicatorsWere !== undefined ? { indicatorsWere } : {}) };
881
+ }
882
+
883
+ /** The single visible WebView matching `matcher`, or the only one on screen. Waits for it like an action target. */
884
+ private async resolveWebView(matcher: Matcher | undefined, opts: SyncOption, deadline: number): Promise<HostNode> {
885
+ if (matcher !== undefined) {
886
+ const node = await this.resolveTarget(matcher, opts, deadline);
887
+ if (this.deps.platform() !== 'web' || node.type === 'iframe') return node;
888
+ // A wrapper matched (e.g. a View with the testID around the iframe): the iframe inside it.
889
+ const inner = findAll([node], { type: 'iframe' });
890
+ if (inner.length === 1) return inner[0] as HostNode;
891
+ throw new QuiescoError('ELEMENT_NOT_FOUND', `${describeMatcher(matcher)} is a ${node.type}, not a WebView (no single <iframe> inside it)`, { matcher });
892
+ }
893
+ for (;;) {
894
+ await this.syncIfNeeded(opts, Math.max(1, deadline - this.clock.now()));
895
+ const viewport = this.adapter.viewport();
896
+ const all = findAll(this.adapter.getHostRoots(), {}).filter((n) => isWebViewHost(n.type) && isVisible(n, viewport));
897
+ // A WebView's native view inside another WebView host (a wrapper with the same name): the outermost.
898
+ const outer = all.filter((n) => !all.some((o) => o !== n && isAncestor(o, n)));
899
+ if (outer.length === 1) return outer[0] as HostNode;
900
+ if (outer.length > 1) {
901
+ throw new QuiescoError('MULTIPLE_MATCHES', `${outer.length} WebViews are on screen; pass a matcher to app.webview() (e.g. its testID)`, { matcher: {}, matches: outer.map((n) => this.summarize(n)) });
902
+ }
903
+ if (this.clock.now() >= deadline) throw new QuiescoError('ELEMENT_NOT_FOUND', 'No WebView on screen (react-native-webview, or an <iframe> on the web)', { matcher: {} });
904
+ await this.sleep(SyncDefaults.retryIntervalMs);
905
+ }
906
+ }
907
+
908
+ private async assert(spec: AssertionSpec, opts: SyncOption): Promise<ResultOf<AgentMethods['assert']>> {
909
+ const timeoutMs = this.timeout(opts);
910
+ const deadline = this.clock.now() + timeoutMs;
911
+ let synced = false;
912
+ for (;;) {
913
+ try {
914
+ await this.syncIfNeeded(opts, Math.max(1, deadline - this.clock.now()));
915
+ synced = true;
916
+ } catch (e) {
917
+ // Once the app has been idle in this call, a later retry's sync cut short by the deadline (a
918
+ // few ms left, less than the frames frame-stable needs) must not hide the real outcome: the
919
+ // expectation did not hold. Never idle at all stays a SYNC_TIMEOUT.
920
+ if (!(synced && e instanceof QuiescoError && e.errorName === 'SYNC_TIMEOUT')) throw e;
921
+ }
922
+ const ev = evaluate(this.adapter.getHostRoots(), spec, this.adapter.viewport());
923
+ if (ev.pass) return { elements: ev.elements };
924
+ if (this.clock.now() >= deadline) {
925
+ throw new QuiescoError('ASSERTION_FAILED', assertionMessage(spec, ev, timeoutMs, describeMatcher(spec.matcher)), {
926
+ expected: ev.expected,
927
+ actual: ev.actual,
928
+ elements: ev.elements,
929
+ });
930
+ }
931
+ await this.sleep(SyncDefaults.retryIntervalMs);
932
+ }
933
+ }
934
+
935
+ private defaultTrackers(): IdleTracker[] {
936
+ this.network = new NetworkTracker({ target: this.global });
937
+ this.timer = new TimerTracker({ target: this.global as unknown as TimerGlobals });
938
+ this.frames = new FrameStableTracker({
939
+ snapshot: () => {
940
+ const roots = this.adapter.getHostRoots();
941
+ return structureLines(roots, this.ignoredForFrames(roots));
942
+ },
943
+ });
944
+ return [
945
+ this.network,
946
+ this.timer,
947
+ new ReactTracker(this.adapter),
948
+ (this.idleCallbacks = new IdleCallbackTracker({ target: this.global })),
949
+ this.frames,
950
+ new NativeSettleTracker(() => this.nativeModule()),
951
+ ];
952
+ }
953
+ }
954
+
955
+ /** Host types of a WebView: react-native-webview's native component, or an iframe on the web. */
956
+ function isWebViewHost(type: string): boolean {
957
+ return type === 'iframe' || /^RNC?WebView$/.test(type);
958
+ }
959
+
960
+ function isAncestor(a: HostNode, b: HostNode): boolean {
961
+ for (let p = b.parent; p !== null; p = p.parent) if (p === a || p.isSameElement(a)) return true;
962
+ return false;
963
+ }
964
+
965
+ /** Whether two rects share some area (touching edges do not count). */
966
+ function overlaps(a: Rect, b: Rect): boolean {
967
+ return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
968
+ }
969
+
970
+ /** Number of rows of a VirtualizedList-like component from its props, or null if unknown. */
971
+ function listLength(props: Record<string, unknown>): number | null {
972
+ const getItemCount = props['getItemCount'];
973
+ try {
974
+ if (typeof getItemCount === 'function') {
975
+ const n = (getItemCount as (d: unknown) => unknown)(props['data']);
976
+ if (typeof n === 'number') return n;
977
+ }
978
+ } catch {
979
+ // fall through
980
+ }
981
+ const data = props['data'];
982
+ return Array.isArray(data) ? data.length : null;
983
+ }
984
+
985
+ /**
986
+ * Highest row index a VirtualizedList has measured: RN 0.88 `_listMetrics.getHighestMeasuredCellIndex()`,
987
+ * react-native-web `_highestMeasuredFrameIndex`; FlatList keeps its VirtualizedList in `_listRef`. Null
988
+ * when unknown (other list implementations: their scrollToIndex is called directly, with a fallback).
989
+ */
990
+ function highestMeasuredRow(raw: Record<string, unknown>): number | null {
991
+ const isList = (o: Record<string, unknown>): boolean => typeof o['_listMetrics'] === 'object' || typeof o['_highestMeasuredFrameIndex'] === 'number';
992
+ const list = (isList(raw) ? raw : raw['_listRef']) as Record<string, unknown> | null | undefined;
993
+ if (list === null || list === undefined) return null;
994
+ // react-native-web's vendored VirtualizedList keeps the older field.
995
+ if (typeof list['_highestMeasuredFrameIndex'] === 'number') return list['_highestMeasuredFrameIndex'];
996
+ const metrics = list['_listMetrics'] as { getHighestMeasuredCellIndex?: () => unknown } | undefined;
997
+ try {
998
+ const n = metrics?.getHighestMeasuredCellIndex?.();
999
+ return typeof n === 'number' ? n : null;
1000
+ } catch {
1001
+ return null;
1002
+ }
1003
+ }