@limrun/ui 0.9.0-rc.4 → 0.9.0-rc.6
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/dist/components/inspect-overlay.d.ts +33 -0
- package/dist/components/remote-control.d.ts +86 -0
- package/dist/core/ax-fetcher.d.ts +49 -0
- package/dist/core/ax-tree.d.ts +99 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1485 -778
- package/package.json +7 -3
- package/src/components/inspect-overlay.css +223 -0
- package/src/components/inspect-overlay.tsx +437 -0
- package/src/components/remote-control.tsx +547 -9
- package/src/core/ax-fetcher.test.ts +418 -0
- package/src/core/ax-fetcher.ts +377 -0
- package/src/core/ax-tree.test.ts +491 -0
- package/src/core/ax-tree.ts +416 -0
- package/src/demo.tsx +93 -10
- package/src/index.ts +17 -0
- package/vitest.config.ts +23 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Driver that fetches accessibility snapshots over an existing WebSocket and
|
|
2
|
+
// emits change-detected updates to subscribers. Reuses the RemoteControl's
|
|
3
|
+
// signaling WS (not a separate connection) and routes responses by request id.
|
|
4
|
+
//
|
|
5
|
+
// Cadence:
|
|
6
|
+
// - Base interval (default 500ms) right after a fetch resolved.
|
|
7
|
+
// - Doubles up to maxBackoff (default 2000ms) while consecutive snapshots
|
|
8
|
+
// are byte-identical.
|
|
9
|
+
// - Jumps to UNAVAILABLE_RETRY_INTERVAL_MS when the server reports the AX
|
|
10
|
+
// subsystem isn't usable yet.
|
|
11
|
+
// - On bumpActivity() (called by RemoteControl after any user input), the
|
|
12
|
+
// backoff is reset to base — UI almost certainly changed.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
AX_UNAVAILABLE_ERROR,
|
|
16
|
+
AxPlatform,
|
|
17
|
+
AxSnapshot,
|
|
18
|
+
axSnapshotsEqual,
|
|
19
|
+
normalizeAndroidTree,
|
|
20
|
+
normalizeIosTree,
|
|
21
|
+
} from './ax-tree';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_BASE_INTERVAL_MS = 500;
|
|
24
|
+
const DEFAULT_MAX_BACKOFF_MS = 2000;
|
|
25
|
+
const UNAVAILABLE_RETRY_INTERVAL_MS = 5000;
|
|
26
|
+
const REQUEST_TIMEOUT_MS = 8000;
|
|
27
|
+
// After a user-driven event (tap/scroll/openUrl/etc.) we enter a brief
|
|
28
|
+
// "boost" window during which scheduled fetches happen on a shorter
|
|
29
|
+
// interval. This catches mid- and post-animation UI states without
|
|
30
|
+
// hammering the server during idle time.
|
|
31
|
+
const ACTIVITY_BOOST_DURATION_MS = 1200;
|
|
32
|
+
const ACTIVITY_BOOST_INTERVAL_MS = 250;
|
|
33
|
+
|
|
34
|
+
// Coarse-grained status surfaced to customers so they can render readiness
|
|
35
|
+
// indicators / error UI in their own panels.
|
|
36
|
+
//
|
|
37
|
+
// State machine:
|
|
38
|
+
// idle ───start()───▶ starting ──snapshot────▶ ready
|
|
39
|
+
// ──AX_UNAVAILABLE_ERROR─▶ unavailable
|
|
40
|
+
// ──other error──▶ error
|
|
41
|
+
// ready ──AX_UNAVAILABLE_ERROR─▶ unavailable
|
|
42
|
+
// ──other error──▶ error (transient; back to ready on next success)
|
|
43
|
+
// unavailable ──snapshot────▶ ready
|
|
44
|
+
// error ──snapshot────▶ ready
|
|
45
|
+
// * stop() ────────▶ idle
|
|
46
|
+
//
|
|
47
|
+
// Note `error` is sticky-but-recoverable: the fetcher keeps polling, and as
|
|
48
|
+
// soon as a fresh snapshot arrives we transition back to `ready`. Customers
|
|
49
|
+
// don't need to manually retry.
|
|
50
|
+
export type AxStatus = 'idle' | 'starting' | 'ready' | 'unavailable' | 'error';
|
|
51
|
+
|
|
52
|
+
export type AxFetcherSendFn = (payload: Record<string, unknown>) => boolean;
|
|
53
|
+
|
|
54
|
+
export interface AxFetcherOptions {
|
|
55
|
+
platform: AxPlatform;
|
|
56
|
+
send: AxFetcherSendFn;
|
|
57
|
+
onSnapshot: (snapshot: AxSnapshot | null) => void;
|
|
58
|
+
// Optional: notified on every status transition (deduplicated — no
|
|
59
|
+
// self-loops are emitted). `error` provides the error message when the
|
|
60
|
+
// status is `error` or `unavailable`.
|
|
61
|
+
onStatusChange?: (status: AxStatus, error?: string) => void;
|
|
62
|
+
baseIntervalMs?: number;
|
|
63
|
+
maxBackoffMs?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type PendingResolver = {
|
|
67
|
+
resolve: (snapshot: AxSnapshot) => void;
|
|
68
|
+
reject: (err: Error) => void;
|
|
69
|
+
timer: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Returns the request id used so the caller can route the matching response
|
|
73
|
+
// back. We use ax-rc-{ts}-{rand} so it's easy to distinguish from screenshot
|
|
74
|
+
// ids in debug output.
|
|
75
|
+
const generateRequestId = (): string => `ax-rc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
76
|
+
|
|
77
|
+
export class AxFetcher {
|
|
78
|
+
private readonly platform: AxPlatform;
|
|
79
|
+
private readonly send: AxFetcherSendFn;
|
|
80
|
+
private readonly onSnapshot: (snapshot: AxSnapshot | null) => void;
|
|
81
|
+
private readonly onStatusChange?: (status: AxStatus, error?: string) => void;
|
|
82
|
+
private readonly baseIntervalMs: number;
|
|
83
|
+
private readonly maxBackoffMs: number;
|
|
84
|
+
private readonly pending: Map<string, PendingResolver> = new Map();
|
|
85
|
+
|
|
86
|
+
private running = false;
|
|
87
|
+
private timer: number | undefined;
|
|
88
|
+
private currentInterval: number;
|
|
89
|
+
private lastSnapshot: AxSnapshot | null = null;
|
|
90
|
+
// Single-flight: only one outstanding request at a time. Avoids piling
|
|
91
|
+
// identical requests on a slow server.
|
|
92
|
+
private inflight = false;
|
|
93
|
+
// Rate-limit floor for bumpActivity so high-frequency input (drags,
|
|
94
|
+
// typing) doesn't trigger a request storm.
|
|
95
|
+
private lastBumpAtMs = 0;
|
|
96
|
+
// Wall-clock timestamp until which we're in "activity boost" mode and
|
|
97
|
+
// schedule fetches at ACTIVITY_BOOST_INTERVAL_MS instead of the normal
|
|
98
|
+
// backed-off interval. Set by bumpActivity().
|
|
99
|
+
private boostUntilMs = 0;
|
|
100
|
+
// Coarse-grained current status; deduplicated before emission so
|
|
101
|
+
// identical-to-current transitions are no-ops.
|
|
102
|
+
private status: AxStatus = 'idle';
|
|
103
|
+
|
|
104
|
+
constructor(opts: AxFetcherOptions) {
|
|
105
|
+
this.platform = opts.platform;
|
|
106
|
+
this.send = opts.send;
|
|
107
|
+
this.onSnapshot = opts.onSnapshot;
|
|
108
|
+
this.onStatusChange = opts.onStatusChange;
|
|
109
|
+
this.baseIntervalMs = opts.baseIntervalMs ?? DEFAULT_BASE_INTERVAL_MS;
|
|
110
|
+
this.maxBackoffMs = opts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
111
|
+
this.currentInterval = this.baseIntervalMs;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
start(): void {
|
|
115
|
+
if (this.running) return;
|
|
116
|
+
this.running = true;
|
|
117
|
+
this.currentInterval = this.baseIntervalMs;
|
|
118
|
+
this.boostUntilMs = 0;
|
|
119
|
+
this.setStatus('starting');
|
|
120
|
+
// Kick a fetch immediately on enable so the overlay shows up fast.
|
|
121
|
+
void this.runOnce();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
stop(): void {
|
|
125
|
+
if (!this.running) return;
|
|
126
|
+
this.running = false;
|
|
127
|
+
if (this.timer !== undefined) {
|
|
128
|
+
window.clearTimeout(this.timer);
|
|
129
|
+
this.timer = undefined;
|
|
130
|
+
}
|
|
131
|
+
this.failAllPending('Inspector stopped');
|
|
132
|
+
this.lastSnapshot = null;
|
|
133
|
+
this.setStatus('idle');
|
|
134
|
+
this.onSnapshot(null);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getStatus(): AxStatus {
|
|
138
|
+
return this.status;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private setStatus(next: AxStatus, error?: string): void {
|
|
142
|
+
if (this.status === next) return;
|
|
143
|
+
this.status = next;
|
|
144
|
+
if (!this.onStatusChange) return;
|
|
145
|
+
try {
|
|
146
|
+
this.onStatusChange(next, error);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
// Don't let customer status-handler errors break our state machine.
|
|
149
|
+
console.error('[AxFetcher] onStatusChange threw:', err);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Called after any user-driven action that's likely to change the UI
|
|
154
|
+
// (taps, scrolls, key events, openUrl, app termination, orientation
|
|
155
|
+
// change, etc.). Has two effects:
|
|
156
|
+
//
|
|
157
|
+
// 1. Resets the polling interval to base and triggers an immediate
|
|
158
|
+
// fetch (rate-limited so drags / rapid typing don't request-storm).
|
|
159
|
+
// 2. Enters a brief "boost" window during which scheduleNext() uses a
|
|
160
|
+
// shorter interval (ACTIVITY_BOOST_INTERVAL_MS), so we capture the
|
|
161
|
+
// mid- and post-animation tree without waiting for the next
|
|
162
|
+
// back-off cycle.
|
|
163
|
+
bumpActivity(): void {
|
|
164
|
+
if (!this.running) return;
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
// Extend the boost window on every bump so a rapid sequence of inputs
|
|
167
|
+
// (a swipe-then-tap, scrolling through a list) keeps polling fast
|
|
168
|
+
// throughout the action.
|
|
169
|
+
this.boostUntilMs = now + ACTIVITY_BOOST_DURATION_MS;
|
|
170
|
+
const floorMs = Math.max(150, Math.floor(this.baseIntervalMs / 2));
|
|
171
|
+
if (now - this.lastBumpAtMs < floorMs) {
|
|
172
|
+
// Still reset cadence so the next scheduled fetch lands at base
|
|
173
|
+
// interval, but don't cancel the current timer or trigger an extra
|
|
174
|
+
// immediate fetch.
|
|
175
|
+
this.currentInterval = this.baseIntervalMs;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.lastBumpAtMs = now;
|
|
179
|
+
this.currentInterval = this.baseIntervalMs;
|
|
180
|
+
if (this.timer !== undefined) {
|
|
181
|
+
window.clearTimeout(this.timer);
|
|
182
|
+
this.timer = undefined;
|
|
183
|
+
}
|
|
184
|
+
if (!this.inflight) {
|
|
185
|
+
void this.runOnce();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Fire a one-shot fetch independent of the poll loop. Useful for the
|
|
190
|
+
// imperative refresh() ref method customers may call.
|
|
191
|
+
//
|
|
192
|
+
// Goes through the same change-detect path as the regular poll loop —
|
|
193
|
+
// identical-to-last-snapshot responses do NOT re-emit via onSnapshot, so
|
|
194
|
+
// a customer calling refresh() in a tight loop doesn't see a stream of
|
|
195
|
+
// duplicate snapshots. The returned promise still resolves with the
|
|
196
|
+
// (possibly identical) snapshot for the caller's own use.
|
|
197
|
+
async refresh(): Promise<AxSnapshot> {
|
|
198
|
+
const next = await this.requestOnce();
|
|
199
|
+
if (this.running) {
|
|
200
|
+
this.deliver(next);
|
|
201
|
+
}
|
|
202
|
+
return next;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Returns the latest snapshot delivered to onSnapshot. May be stale.
|
|
206
|
+
getLatest(): AxSnapshot | null {
|
|
207
|
+
return this.lastSnapshot;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Called by RemoteControl's ws.onmessage when it sees a message type we own.
|
|
211
|
+
// Returns true when the message was a response we were waiting for.
|
|
212
|
+
handleMessage(message: { type?: string; id?: string; [k: string]: unknown }): boolean {
|
|
213
|
+
if (!message || typeof message.id !== 'string') return false;
|
|
214
|
+
const id = message.id;
|
|
215
|
+
const resolver = this.pending.get(id);
|
|
216
|
+
if (!resolver) return false;
|
|
217
|
+
|
|
218
|
+
if (this.platform === 'ios') {
|
|
219
|
+
// iOS: {type:'elementTreeResult', id, json, error}
|
|
220
|
+
if (message.type !== 'elementTreeResult') return false;
|
|
221
|
+
const error = typeof message.error === 'string' ? message.error : null;
|
|
222
|
+
if (error) {
|
|
223
|
+
this.settleReject(id, new Error(error));
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
const json = typeof message.json === 'string' ? message.json : '';
|
|
227
|
+
try {
|
|
228
|
+
const parsed = JSON.parse(json);
|
|
229
|
+
const snapshot = normalizeIosTree(parsed);
|
|
230
|
+
this.settleResolve(id, snapshot);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
this.settleReject(id, e instanceof Error ? e : new Error(String(e)));
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// android: {type:'getElementTreeResult', id, payload:{xml,nodes}, error?}
|
|
238
|
+
if (message.type !== 'getElementTreeResult') return false;
|
|
239
|
+
const errObj = message.error as { message?: string; code?: string } | undefined;
|
|
240
|
+
if (errObj && typeof errObj === 'object') {
|
|
241
|
+
const msg = typeof errObj.message === 'string' ? errObj.message : 'getElementTree failed';
|
|
242
|
+
this.settleReject(id, new Error(msg));
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
const payload = message.payload as { nodes?: unknown[] } | undefined;
|
|
246
|
+
const nodes = Array.isArray(payload?.nodes) ? (payload!.nodes as Record<string, unknown>[]) : [];
|
|
247
|
+
try {
|
|
248
|
+
const snapshot = normalizeAndroidTree(nodes as Parameters<typeof normalizeAndroidTree>[0]);
|
|
249
|
+
this.settleResolve(id, snapshot);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
this.settleReject(id, e instanceof Error ? e : new Error(String(e)));
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private buildRequest(id: string): Record<string, unknown> {
|
|
257
|
+
if (this.platform === 'ios') {
|
|
258
|
+
return { type: 'elementTree', id };
|
|
259
|
+
}
|
|
260
|
+
return { type: 'getElementTree', id };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async requestOnce(): Promise<AxSnapshot> {
|
|
264
|
+
const id = generateRequestId();
|
|
265
|
+
return new Promise<AxSnapshot>((resolve, reject) => {
|
|
266
|
+
const timer = window.setTimeout(() => {
|
|
267
|
+
this.pending.delete(id);
|
|
268
|
+
reject(new Error('elementTree request timed out'));
|
|
269
|
+
}, REQUEST_TIMEOUT_MS);
|
|
270
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
271
|
+
const ok = this.send(this.buildRequest(id));
|
|
272
|
+
if (!ok) {
|
|
273
|
+
window.clearTimeout(timer);
|
|
274
|
+
this.pending.delete(id);
|
|
275
|
+
reject(new Error('elementTree send failed (WebSocket not open)'));
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private async runOnce(): Promise<void> {
|
|
281
|
+
if (!this.running) return;
|
|
282
|
+
if (this.inflight) return;
|
|
283
|
+
this.inflight = true;
|
|
284
|
+
try {
|
|
285
|
+
const next = await this.requestOnce();
|
|
286
|
+
if (!this.running) return;
|
|
287
|
+
this.deliver(next);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
// Don't surface transient errors to the customer's onSnapshot — just
|
|
290
|
+
// back off and try again. We DO surface them via status transitions
|
|
291
|
+
// so customers building their own UI can show a banner / spinner /
|
|
292
|
+
// etc. Persistent failures eventually settle on the longest backoff.
|
|
293
|
+
//
|
|
294
|
+
// If the fetcher was stopped while a request was in flight, the
|
|
295
|
+
// pending request is rejected by failAllPending() and we end up here
|
|
296
|
+
// with running=false. In that case skip the status transition — the
|
|
297
|
+
// current status is already 'idle' and we shouldn't churn it back to
|
|
298
|
+
// an error.
|
|
299
|
+
if (!this.running) return;
|
|
300
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
301
|
+
const unavailable = /unavailable|not (yet )?running|failed|timeout/i.test(message);
|
|
302
|
+
this.currentInterval =
|
|
303
|
+
unavailable ? UNAVAILABLE_RETRY_INTERVAL_MS : Math.min(this.currentInterval * 2, this.maxBackoffMs);
|
|
304
|
+
this.setStatus(unavailable ? 'unavailable' : 'error', message);
|
|
305
|
+
} finally {
|
|
306
|
+
this.inflight = false;
|
|
307
|
+
this.scheduleNext();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private deliver(next: AxSnapshot): void {
|
|
312
|
+
const previous = this.lastSnapshot;
|
|
313
|
+
if (axSnapshotsEqual(previous, next)) {
|
|
314
|
+
// Same payload — back off so we don't churn.
|
|
315
|
+
this.currentInterval = Math.min(this.currentInterval * 2, this.maxBackoffMs);
|
|
316
|
+
// Still update capturedAt by replacing the cached snapshot.
|
|
317
|
+
this.lastSnapshot = next;
|
|
318
|
+
// A successful (even if unchanged) fetch is a sign that AX is
|
|
319
|
+
// working — emit `ready` if we were in a degraded status.
|
|
320
|
+
if (!next.errors?.includes(AX_UNAVAILABLE_ERROR)) {
|
|
321
|
+
this.setStatus('ready');
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.lastSnapshot = next;
|
|
326
|
+
this.currentInterval = this.baseIntervalMs;
|
|
327
|
+
if (next.errors?.includes(AX_UNAVAILABLE_ERROR)) {
|
|
328
|
+
this.currentInterval = UNAVAILABLE_RETRY_INTERVAL_MS;
|
|
329
|
+
this.setStatus('unavailable', AX_UNAVAILABLE_ERROR);
|
|
330
|
+
} else {
|
|
331
|
+
this.setStatus('ready');
|
|
332
|
+
}
|
|
333
|
+
this.onSnapshot(next);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private scheduleNext(): void {
|
|
337
|
+
if (!this.running) return;
|
|
338
|
+
if (this.timer !== undefined) {
|
|
339
|
+
window.clearTimeout(this.timer);
|
|
340
|
+
}
|
|
341
|
+
// While in activity-boost mode, cap the wait so we keep capturing the
|
|
342
|
+
// UI through any animation that's mid-flight. Outside the boost
|
|
343
|
+
// window, use the normal (possibly backed-off) interval.
|
|
344
|
+
const interval =
|
|
345
|
+
Date.now() < this.boostUntilMs ?
|
|
346
|
+
Math.min(ACTIVITY_BOOST_INTERVAL_MS, this.currentInterval)
|
|
347
|
+
: this.currentInterval;
|
|
348
|
+
this.timer = window.setTimeout(() => {
|
|
349
|
+
this.timer = undefined;
|
|
350
|
+
void this.runOnce();
|
|
351
|
+
}, interval);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private settleResolve(id: string, snapshot: AxSnapshot): void {
|
|
355
|
+
const r = this.pending.get(id);
|
|
356
|
+
if (!r) return;
|
|
357
|
+
window.clearTimeout(r.timer);
|
|
358
|
+
this.pending.delete(id);
|
|
359
|
+
r.resolve(snapshot);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private settleReject(id: string, err: Error): void {
|
|
363
|
+
const r = this.pending.get(id);
|
|
364
|
+
if (!r) return;
|
|
365
|
+
window.clearTimeout(r.timer);
|
|
366
|
+
this.pending.delete(id);
|
|
367
|
+
r.reject(err);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private failAllPending(reason: string): void {
|
|
371
|
+
for (const [id, r] of this.pending.entries()) {
|
|
372
|
+
window.clearTimeout(r.timer);
|
|
373
|
+
r.reject(new Error(reason));
|
|
374
|
+
this.pending.delete(id);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|