@quiesco/agent 0.0.0-stage → 0.1.0
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,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real scrolling of wheel pickers (iOS UIPickerView, and UIDatePicker in wheels style) in native mode
|
|
3
|
+
* (HU 2026-11-09; findings.md "6.3 spike").
|
|
4
|
+
*
|
|
5
|
+
* The native module reports each column's touch area (found by hit testing), row height, selected row
|
|
6
|
+
* and — for a date picker — the date. A wheel is turned by a slow drag that rests before the finger
|
|
7
|
+
* lifts, so it does not fling; the result is read back once the wheel has stopped and corrected with
|
|
8
|
+
* further drags. Date pickers: which calendar unit a column turns (year, month, day, hour ...) depends
|
|
9
|
+
* on the locale, so each column is probed with a one-row drag and the date before / after compared.
|
|
10
|
+
*/
|
|
11
|
+
import { QuiescoError } from '../errors';
|
|
12
|
+
import type { Clock } from '../sync';
|
|
13
|
+
|
|
14
|
+
/** The parts of @quiesco/native's wheelInfo used here. */
|
|
15
|
+
export interface WheelColumn {
|
|
16
|
+
selectedRow: number;
|
|
17
|
+
rows: number;
|
|
18
|
+
rowHeight: number;
|
|
19
|
+
title: string | null;
|
|
20
|
+
x?: number;
|
|
21
|
+
y?: number;
|
|
22
|
+
width?: number;
|
|
23
|
+
height?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WheelState {
|
|
27
|
+
found: boolean;
|
|
28
|
+
components?: WheelColumn[];
|
|
29
|
+
moving?: boolean;
|
|
30
|
+
x?: number;
|
|
31
|
+
y?: number;
|
|
32
|
+
width?: number;
|
|
33
|
+
height?: number;
|
|
34
|
+
date?: number;
|
|
35
|
+
/** UIDatePicker.Mode: 0 time, 1 date, 2 date and time, 3 countdown. */
|
|
36
|
+
datePickerMode?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WheelContext {
|
|
40
|
+
read(): Promise<WheelState>;
|
|
41
|
+
/** One finger along a timed path ([point, ms]), window coordinates. */
|
|
42
|
+
gesture(path: [{ x: number; y: number }, number][]): Promise<void>;
|
|
43
|
+
clock: Clock;
|
|
44
|
+
deadline: number;
|
|
45
|
+
/** Window height, to keep long drags on screen. */
|
|
46
|
+
viewportHeight: number | null;
|
|
47
|
+
platform: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const POLL_MS = 40;
|
|
51
|
+
/** Readings that must agree (wheel at rest and values unchanged) before a wheel counts as stopped. */
|
|
52
|
+
const STABLE_READS = 3;
|
|
53
|
+
/** The finger rests this long before lifting: no velocity, no fling. */
|
|
54
|
+
const REST_MS = 250;
|
|
55
|
+
const PRESS_MS = 60;
|
|
56
|
+
const MAX_TRAVEL = 360;
|
|
57
|
+
const MAX_ATTEMPTS = 12;
|
|
58
|
+
|
|
59
|
+
function sleep(clock: Clock, ms: number): Promise<void> {
|
|
60
|
+
return new Promise((r) => {
|
|
61
|
+
clock.setTimeout(r, ms);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function snapshot(s: WheelState): string {
|
|
66
|
+
return JSON.stringify([s.date ?? null, (s.components ?? []).map((c) => c.selectedRow)]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The wheel once it has stopped: not moving, and the same selection / date over a few readings. */
|
|
70
|
+
export async function settle(ctx: WheelContext): Promise<WheelState> {
|
|
71
|
+
let last = '';
|
|
72
|
+
let same = 0;
|
|
73
|
+
for (;;) {
|
|
74
|
+
const s = await ctx.read();
|
|
75
|
+
if (!s.found) throw new QuiescoError('ELEMENT_NOT_FOUND', 'The wheel picker disappeared', { matcher: {} });
|
|
76
|
+
const key = snapshot(s);
|
|
77
|
+
same = s.moving !== true && key === last ? same + 1 : 0;
|
|
78
|
+
last = key;
|
|
79
|
+
if (same >= STABLE_READS - 1 || ctx.clock.now() >= ctx.deadline) return s;
|
|
80
|
+
await sleep(ctx.clock, POLL_MS);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function column(s: WheelState, c: number): Required<Pick<WheelColumn, 'x' | 'width'>> & WheelColumn {
|
|
85
|
+
const col = s.components?.[c];
|
|
86
|
+
if (col === undefined || col.x === undefined || col.width === undefined || col.width <= 0) {
|
|
87
|
+
throw new QuiescoError('ACTION_NOT_SUPPORTED', `wheel column ${c} takes no touches (nothing to drag)`, { action: 'setValue', platform: 'native' });
|
|
88
|
+
}
|
|
89
|
+
return col as Required<Pick<WheelColumn, 'x' | 'width'>> & WheelColumn;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Turn column `c` by `rows` (positive: towards later rows, i.e. the finger moves up). At most what fits in one drag. */
|
|
93
|
+
async function turn(ctx: WheelContext, s0: WheelState, c: number, rows: number): Promise<void> {
|
|
94
|
+
// A column takes no touches while UIKit animates it (e.g. the app re-selected the row): wait.
|
|
95
|
+
let s = s0;
|
|
96
|
+
while (!(s.components?.[c]?.width !== undefined && (s.components[c]?.width ?? 0) > 0) && ctx.clock.now() < ctx.deadline) {
|
|
97
|
+
await sleep(ctx.clock, POLL_MS);
|
|
98
|
+
s = await ctx.read();
|
|
99
|
+
}
|
|
100
|
+
const col = column(s, c);
|
|
101
|
+
const top = s.y ?? 0;
|
|
102
|
+
const bottom = top + (s.height ?? 0);
|
|
103
|
+
const maxRows = Math.max(1, Math.floor(MAX_TRAVEL / col.rowHeight));
|
|
104
|
+
const n = Math.max(-maxRows, Math.min(maxRows, rows));
|
|
105
|
+
const d = Math.abs(n) * col.rowHeight;
|
|
106
|
+
const x = col.x + col.width / 2;
|
|
107
|
+
// The finger must go down on the wheel; it may leave it while moving (the scroll view keeps tracking).
|
|
108
|
+
// Turning to later rows moves the finger up: start low on the wheel, and vice versa.
|
|
109
|
+
const cy = (top + bottom) / 2;
|
|
110
|
+
const inset = Math.min(col.rowHeight, (bottom - top) / 4);
|
|
111
|
+
let from = n > 0 ? Math.min(cy + d / 2, bottom - inset) : Math.max(cy - d / 2, top + inset);
|
|
112
|
+
let to = n > 0 ? from - d : from + d;
|
|
113
|
+
// Keep the finger on screen.
|
|
114
|
+
const h = ctx.viewportHeight;
|
|
115
|
+
if (h !== null) {
|
|
116
|
+
if (to < 4) to = 4;
|
|
117
|
+
if (to > h - 4) to = h - 4;
|
|
118
|
+
from = n > 0 ? Math.min(from, to + d) : Math.max(from, to - d);
|
|
119
|
+
}
|
|
120
|
+
const moveMs = 150 + d * 3;
|
|
121
|
+
await ctx.gesture([
|
|
122
|
+
[{ x, y: from }, 0],
|
|
123
|
+
[{ x, y: from }, PRESS_MS],
|
|
124
|
+
[{ x, y: to }, PRESS_MS + moveMs],
|
|
125
|
+
[{ x, y: to }, PRESS_MS + moveMs + REST_MS],
|
|
126
|
+
]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function giveUp(what: string, platform: string): QuiescoError {
|
|
130
|
+
return new QuiescoError('ACTION_NOT_SUPPORTED', `setValue could not turn the wheel to ${what}`, {
|
|
131
|
+
action: 'setValue',
|
|
132
|
+
platform,
|
|
133
|
+
hint: 'the wheel did not land on the target after several drags; pass { mode: "js" } to set it through its callbacks',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Turn column `c` until row `target` is selected. */
|
|
138
|
+
export async function selectRow(ctx: WheelContext, c: number, target: number): Promise<WheelState> {
|
|
139
|
+
let s = await settle(ctx);
|
|
140
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
141
|
+
const cur = s.components?.[c]?.selectedRow ?? -1;
|
|
142
|
+
if (cur === target) return s;
|
|
143
|
+
await turn(ctx, s, c, target - cur);
|
|
144
|
+
s = await settle(ctx);
|
|
145
|
+
}
|
|
146
|
+
if ((s.components?.[c]?.selectedRow ?? -1) === target) return s;
|
|
147
|
+
throw giveUp(`row ${target} (it shows row ${s.components?.[c]?.selectedRow ?? '?'})`, ctx.platform);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- date pickers ----
|
|
151
|
+
|
|
152
|
+
export type DateUnit = 'year' | 'month' | 'day' | 'date' | 'hour' | 'minute' | 'ampm';
|
|
153
|
+
|
|
154
|
+
interface Fields {
|
|
155
|
+
y: number;
|
|
156
|
+
mo: number;
|
|
157
|
+
d: number;
|
|
158
|
+
h: number;
|
|
159
|
+
mi: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function fields(ms: number): Fields {
|
|
163
|
+
const t = new Date(ms);
|
|
164
|
+
return { y: t.getFullYear(), mo: t.getMonth(), d: t.getDate(), h: t.getHours(), mi: t.getMinutes() };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function dayNumber(f: Fields): number {
|
|
168
|
+
return Math.round(Date.UTC(f.y, f.mo, f.d) / 86400000);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The unit a column turned, from the date before and after a one-row turn. `mode` 2 (date and time)
|
|
173
|
+
* has a single date column (a list of days); `mode` 1 has separate year / month / day wheels.
|
|
174
|
+
*/
|
|
175
|
+
export function classifyTurn(before: number, after: number, mode: number): { unit: DateUnit; step: number } | null {
|
|
176
|
+
const a = fields(before);
|
|
177
|
+
const b = fields(after);
|
|
178
|
+
const sameDay = a.y === b.y && a.mo === b.mo && a.d === b.d;
|
|
179
|
+
const sameTime = a.h === b.h && a.mi === b.mi;
|
|
180
|
+
if (!sameDay && sameTime) {
|
|
181
|
+
if (mode === 2) return { unit: 'date', step: 1 };
|
|
182
|
+
if (a.y !== b.y && a.mo === b.mo) return { unit: 'year', step: 1 };
|
|
183
|
+
if (a.mo !== b.mo) return { unit: 'month', step: 1 };
|
|
184
|
+
return { unit: 'day', step: 1 };
|
|
185
|
+
}
|
|
186
|
+
if (sameDay && !sameTime) {
|
|
187
|
+
if (a.mi !== b.mi && a.h === b.h) {
|
|
188
|
+
const dm = Math.abs(b.mi - a.mi);
|
|
189
|
+
return { unit: 'minute', step: Math.min(dm, 60 - dm) };
|
|
190
|
+
}
|
|
191
|
+
if (Math.abs(b.h - a.h) === 12 && a.mi === b.mi) return { unit: 'ampm', step: 1 };
|
|
192
|
+
return { unit: 'hour', step: 1 };
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Rows to turn a column of `unit` from `cur` to `target` (12-hour wheels count hours modulo 12). */
|
|
198
|
+
export function rowsFor(unit: DateUnit, step: number, cur: Fields, target: Fields, twelveHour: boolean): number {
|
|
199
|
+
switch (unit) {
|
|
200
|
+
case 'year':
|
|
201
|
+
return target.y - cur.y;
|
|
202
|
+
case 'month':
|
|
203
|
+
return target.mo - cur.mo;
|
|
204
|
+
case 'day':
|
|
205
|
+
return target.d - cur.d;
|
|
206
|
+
case 'date':
|
|
207
|
+
return dayNumber(target) - dayNumber(cur);
|
|
208
|
+
case 'hour':
|
|
209
|
+
return twelveHour ? (target.h % 12) - (cur.h % 12) : target.h - cur.h;
|
|
210
|
+
case 'ampm':
|
|
211
|
+
return (target.h >= 12 ? 1 : 0) - (cur.h >= 12 ? 1 : 0);
|
|
212
|
+
case 'minute':
|
|
213
|
+
return Math.round((target.mi - cur.mi) / Math.max(1, step));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function matches(cur: Fields, target: Fields, mode: number): boolean {
|
|
218
|
+
const day = cur.y === target.y && cur.mo === target.mo && cur.d === target.d;
|
|
219
|
+
const time = cur.h === target.h && cur.mi === target.mi;
|
|
220
|
+
return mode === 1 ? day : mode === 0 ? time : day && time;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Turn the wheels of a UIDatePicker until it shows `target` (date part, time part or both by mode). */
|
|
224
|
+
export async function selectDate(ctx: WheelContext, target: Date): Promise<WheelState> {
|
|
225
|
+
let s = await settle(ctx);
|
|
226
|
+
const mode = s.datePickerMode ?? 1;
|
|
227
|
+
if (mode === 3) throw new QuiescoError('ACTION_NOT_SUPPORTED', 'countdown timers are not supported by setValue', { action: 'setValue', platform: ctx.platform });
|
|
228
|
+
const n = s.components?.length ?? 0;
|
|
229
|
+
const twelveHour = (mode === 0 && n === 3) || (mode === 2 && n === 4);
|
|
230
|
+
const t = fields(target.getTime());
|
|
231
|
+
const units: ({ unit: DateUnit; step: number } | null)[] = [];
|
|
232
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
233
|
+
for (let c = 0; c < n; c++) {
|
|
234
|
+
if (s.date === undefined) throw new QuiescoError('ACTION_NOT_SUPPORTED', 'the wheel picker reports no date', { action: 'setValue', platform: ctx.platform });
|
|
235
|
+
if (matches(fields(s.date), t, mode)) return s;
|
|
236
|
+
if (units[c] === undefined) {
|
|
237
|
+
// Probe: one row towards later rows (earlier at the end of a non-cyclic wheel).
|
|
238
|
+
const col = s.components?.[c];
|
|
239
|
+
const dir = col !== undefined && col.selectedRow >= col.rows - 1 ? -1 : 1;
|
|
240
|
+
const before = s.date;
|
|
241
|
+
await turn(ctx, s, c, dir);
|
|
242
|
+
s = await settle(ctx);
|
|
243
|
+
const k = s.date === undefined ? null : classifyTurn(before, s.date, mode);
|
|
244
|
+
units[c] = k;
|
|
245
|
+
}
|
|
246
|
+
const u = units[c];
|
|
247
|
+
if (u === null || u === undefined || s.date === undefined) continue;
|
|
248
|
+
let rows = rowsFor(u.unit, u.step, fields(s.date), t, twelveHour);
|
|
249
|
+
for (let i = 0; rows !== 0 && i < MAX_ATTEMPTS; i++) {
|
|
250
|
+
await turn(ctx, s, c, rows);
|
|
251
|
+
s = await settle(ctx);
|
|
252
|
+
if (s.date === undefined) break;
|
|
253
|
+
rows = rowsFor(u.unit, u.step, fields(s.date), t, twelveHour);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (s.date !== undefined && matches(fields(s.date), t, mode)) return s;
|
|
258
|
+
const shown = s.date === undefined ? '?' : new Date(s.date).toString();
|
|
259
|
+
throw giveUp(`${target.toString()} (it shows ${shown})`, ctx.platform);
|
|
260
|
+
}
|
package/src/api-types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Public app-side API (requirements.md §6.5, §6.6). Shared by index.ts and the production stub. */
|
|
2
|
+
import type { JsonValue } from '@quiesco/protocol';
|
|
3
|
+
|
|
4
|
+
/** A backdoor: test-only function the host can call. Arguments and result must be JSON-serializable. */
|
|
5
|
+
export type Backdoor = (...args: never[]) => unknown;
|
|
6
|
+
|
|
7
|
+
/** Clears one persistent store during soft reset (AsyncStorage, MMKV, SecureStore, ...). */
|
|
8
|
+
export interface StorageAdapter {
|
|
9
|
+
name: string;
|
|
10
|
+
clear(): void | Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface QuiescoApi {
|
|
14
|
+
/** Register a backdoor under `name` (re-registering replaces it). Returns an unregister function. */
|
|
15
|
+
backdoor(name: string, fn: Backdoor): () => void;
|
|
16
|
+
/** Hook run on every soft reset, after storage is cleared and before the app remounts. */
|
|
17
|
+
onReset(hook: () => void | Promise<void>): () => void;
|
|
18
|
+
/** Extra store to clear on soft reset. AsyncStorage (native) / web storage are cleared built-in. */
|
|
19
|
+
registerStorage(adapter: StorageAdapter): () => void;
|
|
20
|
+
/**
|
|
21
|
+
* Arguments the test launched the app with (`app.device.relaunch({ args })`, config `launchArgs`), with
|
|
22
|
+
* their JSON types. `{}` when there are none, in production, and in JS-only native builds.
|
|
23
|
+
*/
|
|
24
|
+
launchArgs(): Record<string, JsonValue>;
|
|
25
|
+
/**
|
|
26
|
+
* Index of the parallel test worker driving this app (0 when tests run serially; Phase 6.12). `null`
|
|
27
|
+
* until the host has connected (it connects before the first test command) and in production. For
|
|
28
|
+
* resources the app needs at startup (an API URL), pass them as launch arguments instead: the config
|
|
29
|
+
* can be a function of the worker index (`({ workerIndex }) => ({ launchArgs: { … } })`).
|
|
30
|
+
*/
|
|
31
|
+
workerIndex(): number | null;
|
|
32
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { QuiescoApi } from './api-types';
|
|
2
|
+
import { getRuntime } from './singleton';
|
|
3
|
+
|
|
4
|
+
/** App-side API: backdoors, reset hooks and extra storage adapters. */
|
|
5
|
+
export const quiesco: QuiescoApi = {
|
|
6
|
+
backdoor: (name, fn) => getRuntime().backdoors.register(name, fn),
|
|
7
|
+
onReset: (hook) => getRuntime().resets.onReset(hook),
|
|
8
|
+
registerStorage: (adapter) => getRuntime().resets.registerStorage(adapter),
|
|
9
|
+
launchArgs: () => getRuntime().launchArgs(),
|
|
10
|
+
workerIndex: () => getRuntime().workerIndex(),
|
|
11
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assertion evaluation (requirements.md §6.4). One evaluation is a pure check of the current tree; the
|
|
3
|
+
* runtime retries it until it passes or the timeout expires.
|
|
4
|
+
*
|
|
5
|
+
* Semantics with several matches (no `index`):
|
|
6
|
+
* - visible: at least one match is visible. notVisible: no match is visible (no match at all passes).
|
|
7
|
+
* - exists: at least one match. notExists: no match. count: number of matches.
|
|
8
|
+
* - text / prop / focused / enabled / checked / value / label (and their negations): exactly one match
|
|
9
|
+
* is required; otherwise the assertion fails (and is retried).
|
|
10
|
+
* - visible with `ratio`: at least one match shows at least that fraction of its area.
|
|
11
|
+
*/
|
|
12
|
+
import type { AssertionSpec, ElementSummary, JsonValue, TextMatch } from '@quiesco/protocol';
|
|
13
|
+
|
|
14
|
+
import { QuiescoError } from '../errors';
|
|
15
|
+
import type { HostNode, Viewport } from '../fiber-adapter';
|
|
16
|
+
import { findAll, summarize, toJsonValue, toRegExp } from '../query';
|
|
17
|
+
|
|
18
|
+
function isTextMatch(v: unknown): v is TextMatch {
|
|
19
|
+
return typeof v === 'string' || (typeof v === 'object' && v !== null && !Array.isArray(v) && 'regex' in v && Object.keys(v).length === 1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Evaluation {
|
|
23
|
+
pass: boolean;
|
|
24
|
+
expected: JsonValue;
|
|
25
|
+
actual: JsonValue;
|
|
26
|
+
elements: ElementSummary[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function describeText(t: TextMatch): string {
|
|
30
|
+
return typeof t === 'string' ? t : `/${t.regex.source}/${t.regex.flags}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function textMatches(value: string, pattern: TextMatch): boolean {
|
|
34
|
+
const p = toRegExp(pattern);
|
|
35
|
+
if (typeof p === 'string') return value === p;
|
|
36
|
+
p.lastIndex = 0;
|
|
37
|
+
return p.test(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A prop as the user wrote it: host props first, then the owning composite (e.g. Pressable `disabled`). */
|
|
41
|
+
export function readProp(node: HostNode, prop: string): unknown {
|
|
42
|
+
const own = node.props[prop];
|
|
43
|
+
return own !== undefined ? own : node.ownerProps()[prop];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function jsonEqual(a: JsonValue | undefined, b: JsonValue | undefined): boolean {
|
|
47
|
+
if (a === b) return true;
|
|
48
|
+
if (a === undefined || b === undefined || a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
49
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
50
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((x, i) => jsonEqual(x, b[i]));
|
|
51
|
+
const ka = Object.keys(a);
|
|
52
|
+
const ao = a as Record<string, JsonValue>;
|
|
53
|
+
const bo = b as Record<string, JsonValue>;
|
|
54
|
+
return ka.length === Object.keys(bo).length && ka.every((k) => jsonEqual(ao[k], bo[k]));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function single(nodes: HostNode[]): HostNode | string {
|
|
58
|
+
if (nodes.length === 1) return nodes[0] as HostNode;
|
|
59
|
+
return nodes.length === 0 ? 'no matching element' : `${nodes.length} matching elements (add index or narrow the matcher)`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function evaluate(roots: readonly HostNode[], spec: AssertionSpec, viewport: Viewport | null): Evaluation {
|
|
63
|
+
const ev = evaluatePositive(roots, spec, viewport);
|
|
64
|
+
if (!('not' in spec) || spec.not !== true) return ev;
|
|
65
|
+
// Negated single-element assertions still need exactly one element to say anything about it.
|
|
66
|
+
const needsOne = spec.type !== 'count';
|
|
67
|
+
if (needsOne && ev.elements.length !== 1) return { ...ev, pass: false, expected: `not ${JSON.stringify(ev.expected)}` };
|
|
68
|
+
return { ...ev, pass: !ev.pass, expected: `not ${JSON.stringify(ev.expected)}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function evaluatePositive(roots: readonly HostNode[], spec: AssertionSpec, viewport: Viewport | null): Evaluation {
|
|
72
|
+
let nodes: HostNode[];
|
|
73
|
+
try {
|
|
74
|
+
nodes = findAll(roots, spec.matcher);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// `within` did not resolve to exactly one element: same as "no match" for this evaluation.
|
|
77
|
+
if (!(e instanceof QuiescoError)) throw e;
|
|
78
|
+
if (spec.type === 'notExists' || spec.type === 'notVisible') return { pass: true, expected: spec.type, actual: e.message, elements: [] };
|
|
79
|
+
return { pass: false, expected: spec.type, actual: e.message, elements: [] };
|
|
80
|
+
}
|
|
81
|
+
const elements = nodes.map((n) => summarize(n, viewport));
|
|
82
|
+
|
|
83
|
+
/** Single-element state assertions: `pick` reads the state from the element's summary. */
|
|
84
|
+
const stateOf = (expected: JsonValue, pick: (e: ElementSummary) => JsonValue | undefined, ok: (actual: JsonValue | undefined) => boolean): Evaluation => {
|
|
85
|
+
const n = single(nodes);
|
|
86
|
+
if (typeof n === 'string') return { pass: false, expected, actual: n, elements };
|
|
87
|
+
const actual = pick(elements[0] as ElementSummary);
|
|
88
|
+
return { pass: ok(actual), expected, actual: actual ?? null, elements };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
switch (spec.type) {
|
|
92
|
+
case 'visible': {
|
|
93
|
+
if (spec.ratio !== undefined) {
|
|
94
|
+
const ratio = spec.ratio;
|
|
95
|
+
const best = elements.reduce((m, e) => Math.max(m, e.visibleRatio), 0);
|
|
96
|
+
const pass = elements.some((e) => e.visibleRatio >= ratio);
|
|
97
|
+
const actual = nodes.length === 0 ? 'no matching element' : `${Math.round(best * 100)}% visible`;
|
|
98
|
+
return { pass, expected: `at least ${Math.round(ratio * 100)}% visible`, actual, elements };
|
|
99
|
+
}
|
|
100
|
+
const pass = elements.some((e) => e.visible);
|
|
101
|
+
return { pass, expected: 'visible', actual: nodes.length === 0 ? 'no matching element' : pass ? 'visible' : 'not visible', elements };
|
|
102
|
+
}
|
|
103
|
+
case 'focused':
|
|
104
|
+
return stateOf('focused', (e) => (e.focused ? 'focused' : 'not focused'), (a) => a === 'focused');
|
|
105
|
+
case 'notFocused':
|
|
106
|
+
return stateOf('not focused', (e) => (e.focused ? 'focused' : 'not focused'), (a) => a === 'not focused');
|
|
107
|
+
case 'enabled':
|
|
108
|
+
return stateOf('enabled', (e) => (e.enabled ? 'enabled' : 'disabled'), (a) => a === 'enabled');
|
|
109
|
+
case 'disabled':
|
|
110
|
+
return stateOf('disabled', (e) => (e.enabled ? 'enabled' : 'disabled'), (a) => a === 'disabled');
|
|
111
|
+
case 'checked':
|
|
112
|
+
return stateOf(true, (e) => e.checked, (a) => a === true);
|
|
113
|
+
case 'notChecked':
|
|
114
|
+
return stateOf(false, (e) => e.checked, (a) => a === false);
|
|
115
|
+
case 'label':
|
|
116
|
+
return stateOf(describeText(spec.expected), (e) => e.label, (a) => typeof a === 'string' && textMatches(a, spec.expected));
|
|
117
|
+
case 'value': {
|
|
118
|
+
const expected = spec.expected;
|
|
119
|
+
if (isTextMatch(expected) && typeof expected !== 'string') {
|
|
120
|
+
return stateOf(describeText(expected), (e) => e.value, (a) => typeof a === 'string' && textMatches(a, expected));
|
|
121
|
+
}
|
|
122
|
+
return stateOf(expected as JsonValue, (e) => e.value, (a) => jsonEqual(a, expected as JsonValue));
|
|
123
|
+
}
|
|
124
|
+
case 'notVisible': {
|
|
125
|
+
if (spec.ratio !== undefined) {
|
|
126
|
+
const ratio = spec.ratio;
|
|
127
|
+
const n = elements.filter((e) => e.visibleRatio >= ratio).length;
|
|
128
|
+
return { pass: n === 0, expected: `less than ${Math.round(ratio * 100)}% visible`, actual: n === 0 ? 'not visible enough' : `${n} element(s) at least ${Math.round(ratio * 100)}% visible`, elements };
|
|
129
|
+
}
|
|
130
|
+
const n = elements.filter((e) => e.visible).length;
|
|
131
|
+
return { pass: n === 0, expected: 'not visible', actual: n === 0 ? 'not visible' : `${n} visible element(s)`, elements };
|
|
132
|
+
}
|
|
133
|
+
case 'exists':
|
|
134
|
+
return { pass: nodes.length > 0, expected: 'exists', actual: nodes.length > 0 ? `${nodes.length} element(s)` : 'no matching element', elements };
|
|
135
|
+
case 'notExists':
|
|
136
|
+
return { pass: nodes.length === 0, expected: 'does not exist', actual: nodes.length === 0 ? 'no matching element' : `${nodes.length} element(s)`, elements };
|
|
137
|
+
case 'count':
|
|
138
|
+
return { pass: nodes.length === spec.expected, expected: spec.expected, actual: nodes.length, elements };
|
|
139
|
+
case 'text': {
|
|
140
|
+
const n = single(nodes);
|
|
141
|
+
const expected = describeText(spec.expected);
|
|
142
|
+
if (typeof n === 'string') return { pass: false, expected, actual: n, elements };
|
|
143
|
+
return { pass: textMatches(n.text, spec.expected), expected, actual: n.text, elements };
|
|
144
|
+
}
|
|
145
|
+
case 'prop': {
|
|
146
|
+
const n = single(nodes);
|
|
147
|
+
if (typeof n === 'string') return { pass: false, expected: spec.expected, actual: n, elements };
|
|
148
|
+
const actual = toJsonValue(readProp(n, spec.prop));
|
|
149
|
+
return { pass: jsonEqual(actual, spec.expected), expected: spec.expected, actual: actual ?? null, elements };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function assertionMessage(spec: AssertionSpec, ev: Evaluation, timeoutMs: number, matcherText: string): string {
|
|
155
|
+
const what = spec.type === 'prop' ? `prop "${spec.prop}" of ${matcherText}` : matcherText;
|
|
156
|
+
if (spec.type === 'checked' || spec.type === 'notChecked') {
|
|
157
|
+
return `expect ${spec.type} failed after ${timeoutMs}ms for ${what}: expected checked=${JSON.stringify(ev.expected)}, actual ${ev.actual === null ? 'no checked state (not a switch, checkbox or radio, and no accessibilityState.checked)' : JSON.stringify(ev.actual)}`;
|
|
158
|
+
}
|
|
159
|
+
return `expect ${spec.type} failed after ${timeoutMs}ms for ${what}: expected ${JSON.stringify(ev.expected)}, actual ${JSON.stringify(ev.actual)}`;
|
|
160
|
+
}
|
package/src/backdoor.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Backdoor registry (requirements.md §6.6). */
|
|
2
|
+
import type { JsonValue } from '@quiesco/protocol';
|
|
3
|
+
|
|
4
|
+
import type { Backdoor } from './api-types';
|
|
5
|
+
import { QuiescoError } from './errors';
|
|
6
|
+
import { findNonJson } from './json-check';
|
|
7
|
+
|
|
8
|
+
export class BackdoorRegistry {
|
|
9
|
+
private readonly fns = new Map<string, Backdoor>();
|
|
10
|
+
|
|
11
|
+
register(name: string, fn: Backdoor): () => void {
|
|
12
|
+
if (typeof name !== 'string' || name === '') throw new TypeError('quiesco.backdoor(name, fn): name must be a non-empty string');
|
|
13
|
+
if (typeof fn !== 'function') throw new TypeError(`quiesco.backdoor('${name}', fn): fn must be a function`);
|
|
14
|
+
this.fns.set(name, fn);
|
|
15
|
+
return () => {
|
|
16
|
+
if (this.fns.get(name) === fn) this.fns.delete(name);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
names(): string[] {
|
|
21
|
+
return [...this.fns.keys()].sort();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Run a backdoor. The caller waits for idle afterwards. */
|
|
25
|
+
async call(name: string, args: readonly JsonValue[]): Promise<JsonValue> {
|
|
26
|
+
const fn = this.fns.get(name);
|
|
27
|
+
if (fn === undefined) {
|
|
28
|
+
const registered = this.names();
|
|
29
|
+
throw new QuiescoError(
|
|
30
|
+
'BACKDOOR_NOT_REGISTERED',
|
|
31
|
+
`No backdoor named "${name}". Registered: ${registered.length > 0 ? registered.join(', ') : '(none)'}`,
|
|
32
|
+
{ name, registered },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const badArg = findNonJson(args, '$args');
|
|
36
|
+
if (badArg !== null) {
|
|
37
|
+
throw new QuiescoError('NOT_SERIALIZABLE', `Backdoor "${name}" argument at ${badArg.path} is not JSON (${badArg.reason})`, { where: 'args', path: badArg.path });
|
|
38
|
+
}
|
|
39
|
+
let value: unknown;
|
|
40
|
+
try {
|
|
41
|
+
value = await (fn as (...a: unknown[]) => unknown)(...args);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
44
|
+
const data = err.stack === undefined ? { name, message: err.message } : { name, message: err.message, stack: err.stack };
|
|
45
|
+
throw new QuiescoError('BACKDOOR_FAILED', `Backdoor "${name}" threw: ${err.message}`, data);
|
|
46
|
+
}
|
|
47
|
+
if (value === undefined) return null;
|
|
48
|
+
const bad = findNonJson(value, '$result');
|
|
49
|
+
if (bad !== null) {
|
|
50
|
+
throw new QuiescoError('NOT_SERIALIZABLE', `Backdoor "${name}" returned a value that is not JSON at ${bad.path} (${bad.reason})`, { where: 'result', path: bad.path });
|
|
51
|
+
}
|
|
52
|
+
return value as JsonValue;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time fallback connection parameters. `withQuiesco()` (metro.cjs) replaces this module with a
|
|
3
|
+
* generated one in e2e builds (docs/spikes/00-summary.md agent §6: in JS-only mode there are no
|
|
4
|
+
* launch parameters, and `EXPO_PUBLIC_*` is not inlined inside node_modules).
|
|
5
|
+
*/
|
|
6
|
+
export const buildConfig: { port: number | null; session: string | null } = { port: null, session: null };
|