@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.
@@ -0,0 +1,418 @@
1
+ // Tests for the AxFetcher state machine. We mock the WebSocket boundary
2
+ // using a fake `send` function + manual `handleMessage` injection, and
3
+ // drive time via vitest's fake timers so the burst/backoff math is
4
+ // deterministic.
5
+ //
6
+ // Default environment is jsdom (see vitest.config.ts) — needed because
7
+ // AxFetcher relies on `window.setTimeout` / `requestAnimationFrame`.
8
+
9
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
10
+ import { AxFetcher, AxStatus } from './ax-fetcher';
11
+ import { AxSnapshot, AX_UNAVAILABLE_ERROR } from './ax-tree';
12
+
13
+ // ────────────────────────────────────────────────────────────────────────────
14
+ // Test harness
15
+ // ────────────────────────────────────────────────────────────────────────────
16
+
17
+ // Minimal iOS tree the server might return; one root with one child, both
18
+ // with usable frames. Used to verify normalizeIosTree wires through.
19
+ const iosTreeJson = JSON.stringify([
20
+ {
21
+ frame: { x: 0, y: 0, width: 100, height: 200 },
22
+ type: 'Application',
23
+ children: [
24
+ {
25
+ frame: { x: 10, y: 10, width: 50, height: 50 },
26
+ type: 'Button',
27
+ AXLabel: 'A',
28
+ AXUniqueId: 'a',
29
+ },
30
+ ],
31
+ },
32
+ ]);
33
+
34
+ const androidPayload = {
35
+ nodes: [
36
+ // screen root
37
+ { parsedBounds: { left: 0, top: 0, right: 1080, bottom: 2400, centerX: 540, centerY: 1200 } },
38
+ // a child
39
+ {
40
+ resourceId: 'foo',
41
+ className: 'android.widget.View',
42
+ parsedBounds: { left: 100, top: 100, right: 200, bottom: 200, centerX: 150, centerY: 150 },
43
+ },
44
+ ],
45
+ };
46
+
47
+ interface Harness {
48
+ fetcher: AxFetcher;
49
+ send: ReturnType<typeof vi.fn>;
50
+ onSnapshot: ReturnType<typeof vi.fn>;
51
+ onStatusChange: ReturnType<typeof vi.fn>;
52
+ // Pull the most recent request id we sent (for crafting responses).
53
+ lastRequestId: () => string | null;
54
+ // Helpers to feed a response.
55
+ respondIos: (id: string, opts?: { error?: string; json?: string }) => void;
56
+ respondAndroid: (id: string, opts?: { errorMessage?: string }) => void;
57
+ }
58
+
59
+ const makeIosHarness = (opts: { baseIntervalMs?: number; maxBackoffMs?: number } = {}): Harness => {
60
+ const send = vi.fn((payload: Record<string, unknown>) => {
61
+ // Accept all sends in tests; track by capturing the args.
62
+ return true;
63
+ });
64
+ const onSnapshot = vi.fn();
65
+ const onStatusChange = vi.fn();
66
+ const fetcher = new AxFetcher({
67
+ platform: 'ios',
68
+ send,
69
+ onSnapshot,
70
+ onStatusChange,
71
+ baseIntervalMs: opts.baseIntervalMs,
72
+ maxBackoffMs: opts.maxBackoffMs,
73
+ });
74
+ const lastRequestId = (): string | null => {
75
+ if (send.mock.calls.length === 0) return null;
76
+ const last = send.mock.calls[send.mock.calls.length - 1]![0] as { id?: string };
77
+ return last.id ?? null;
78
+ };
79
+ const respondIos: Harness['respondIos'] = (id, { error, json } = {}) => {
80
+ fetcher.handleMessage({
81
+ type: 'elementTreeResult',
82
+ id,
83
+ json: error ? undefined : json ?? iosTreeJson,
84
+ error,
85
+ });
86
+ };
87
+ const respondAndroid: Harness['respondAndroid'] = () => {
88
+ throw new Error('android responses not available on an iOS harness');
89
+ };
90
+ return { fetcher, send, onSnapshot, onStatusChange, lastRequestId, respondIos, respondAndroid };
91
+ };
92
+
93
+ const makeAndroidHarness = (): Harness => {
94
+ const send = vi.fn(() => true);
95
+ const onSnapshot = vi.fn();
96
+ const onStatusChange = vi.fn();
97
+ const fetcher = new AxFetcher({
98
+ platform: 'android',
99
+ send,
100
+ onSnapshot,
101
+ onStatusChange,
102
+ });
103
+ const lastRequestId = (): string | null => {
104
+ if (send.mock.calls.length === 0) return null;
105
+ const last = send.mock.calls[send.mock.calls.length - 1]![0] as { id?: string };
106
+ return last.id ?? null;
107
+ };
108
+ const respondIos: Harness['respondIos'] = () => {
109
+ throw new Error('iOS responses not available on an android harness');
110
+ };
111
+ const respondAndroid: Harness['respondAndroid'] = (id, { errorMessage } = {}) => {
112
+ fetcher.handleMessage({
113
+ type: 'getElementTreeResult',
114
+ id,
115
+ payload: errorMessage ? undefined : androidPayload,
116
+ error: errorMessage ? { message: errorMessage } : undefined,
117
+ });
118
+ };
119
+ return { fetcher, send, onSnapshot, onStatusChange, lastRequestId, respondIos, respondAndroid };
120
+ };
121
+
122
+ // ────────────────────────────────────────────────────────────────────────────
123
+ // Tests
124
+ // ────────────────────────────────────────────────────────────────────────────
125
+
126
+ beforeEach(() => {
127
+ vi.useFakeTimers();
128
+ });
129
+
130
+ afterEach(() => {
131
+ vi.useRealTimers();
132
+ vi.restoreAllMocks();
133
+ });
134
+
135
+ describe('AxFetcher: basic lifecycle', () => {
136
+ test('start() emits starting → ready status and delivers first snapshot', async () => {
137
+ const h = makeIosHarness();
138
+ h.fetcher.start();
139
+ expect(h.fetcher.getStatus()).toBe('starting');
140
+ expect(h.onStatusChange).toHaveBeenCalledWith('starting', undefined);
141
+
142
+ // start() schedules an immediate runOnce, which calls send synchronously.
143
+ expect(h.send).toHaveBeenCalledTimes(1);
144
+ const id = h.lastRequestId();
145
+ expect(id).toMatch(/^ax-rc-/);
146
+
147
+ h.respondIos(id!);
148
+ // Snapshot delivery happens after the promise chain resolves.
149
+ await vi.runOnlyPendingTimersAsync();
150
+
151
+ expect(h.onSnapshot).toHaveBeenCalledTimes(1);
152
+ const snapshot = h.onSnapshot.mock.calls[0]![0] as AxSnapshot;
153
+ expect(snapshot.platform).toBe('ios');
154
+ expect(snapshot.elements).toHaveLength(1);
155
+ expect(h.fetcher.getStatus()).toBe('ready');
156
+ expect(h.onStatusChange).toHaveBeenLastCalledWith('ready', undefined);
157
+ });
158
+
159
+ test('stop() emits idle and a final null snapshot', async () => {
160
+ const h = makeIosHarness();
161
+ h.fetcher.start();
162
+ h.respondIos(h.lastRequestId()!);
163
+ await vi.runOnlyPendingTimersAsync();
164
+
165
+ h.fetcher.stop();
166
+ expect(h.fetcher.getStatus()).toBe('idle');
167
+ expect(h.onStatusChange).toHaveBeenLastCalledWith('idle', undefined);
168
+ expect(h.onSnapshot).toHaveBeenLastCalledWith(null);
169
+ });
170
+
171
+ test('start() is idempotent (subsequent start() during running is a no-op)', async () => {
172
+ const h = makeIosHarness();
173
+ h.fetcher.start();
174
+ h.fetcher.start();
175
+ h.fetcher.start();
176
+ // Only one fetch in flight.
177
+ expect(h.send).toHaveBeenCalledTimes(1);
178
+ });
179
+ });
180
+
181
+ describe('AxFetcher: single-flight', () => {
182
+ test('does not start a second fetch while the first is in flight', async () => {
183
+ const h = makeIosHarness();
184
+ h.fetcher.start();
185
+ // Even if we advance time, no second send happens until the first resolves.
186
+ await vi.advanceTimersByTimeAsync(2000);
187
+ expect(h.send).toHaveBeenCalledTimes(1);
188
+
189
+ // Respond, advance one tick — single follow-up scheduled.
190
+ h.respondIos(h.lastRequestId()!);
191
+ await vi.runOnlyPendingTimersAsync();
192
+ await vi.advanceTimersByTimeAsync(500);
193
+ expect(h.send).toHaveBeenCalledTimes(2);
194
+ });
195
+ });
196
+
197
+ describe('AxFetcher: change-detect backoff', () => {
198
+ test('emits onSnapshot only when content changes', async () => {
199
+ const h = makeIosHarness({ baseIntervalMs: 100, maxBackoffMs: 1000 });
200
+ h.fetcher.start();
201
+ h.respondIos(h.lastRequestId()!);
202
+ await vi.runOnlyPendingTimersAsync();
203
+ expect(h.onSnapshot).toHaveBeenCalledTimes(1);
204
+
205
+ // Same response on next poll — onSnapshot must NOT fire again.
206
+ await vi.advanceTimersByTimeAsync(100);
207
+ h.respondIos(h.lastRequestId()!);
208
+ await vi.runOnlyPendingTimersAsync();
209
+ expect(h.onSnapshot).toHaveBeenCalledTimes(1);
210
+
211
+ // Different response — fires.
212
+ await vi.advanceTimersByTimeAsync(200);
213
+ h.respondIos(h.lastRequestId()!, {
214
+ json: JSON.stringify([
215
+ {
216
+ frame: { x: 0, y: 0, width: 100, height: 200 },
217
+ type: 'Application',
218
+ children: [
219
+ {
220
+ frame: { x: 10, y: 10, width: 50, height: 50 },
221
+ type: 'Button',
222
+ AXLabel: 'B', // changed
223
+ AXUniqueId: 'a',
224
+ },
225
+ ],
226
+ },
227
+ ]),
228
+ });
229
+ await vi.runOnlyPendingTimersAsync();
230
+ expect(h.onSnapshot).toHaveBeenCalledTimes(2);
231
+ });
232
+ });
233
+
234
+ describe('AxFetcher: unavailable status', () => {
235
+ test('transitions to unavailable when the server reports AX is down', async () => {
236
+ const h = makeIosHarness();
237
+ h.fetcher.start();
238
+ h.respondIos(h.lastRequestId()!, { error: AX_UNAVAILABLE_ERROR });
239
+ await vi.runOnlyPendingTimersAsync();
240
+ expect(h.fetcher.getStatus()).toBe('unavailable');
241
+ expect(h.onStatusChange).toHaveBeenLastCalledWith('unavailable', AX_UNAVAILABLE_ERROR);
242
+ });
243
+
244
+ test('recovers to ready when a usable snapshot eventually arrives', async () => {
245
+ const h = makeIosHarness();
246
+ h.fetcher.start();
247
+ h.respondIos(h.lastRequestId()!, { error: AX_UNAVAILABLE_ERROR });
248
+ await vi.runOnlyPendingTimersAsync();
249
+ expect(h.fetcher.getStatus()).toBe('unavailable');
250
+
251
+ // Advance through the unavailable retry interval, then respond OK.
252
+ await vi.advanceTimersByTimeAsync(5000);
253
+ h.respondIos(h.lastRequestId()!);
254
+ await vi.runOnlyPendingTimersAsync();
255
+ expect(h.fetcher.getStatus()).toBe('ready');
256
+ });
257
+ });
258
+
259
+ describe('AxFetcher: error path', () => {
260
+ test('transient parse error transitions to `error` then recovers', async () => {
261
+ const h = makeIosHarness({ baseIntervalMs: 100 });
262
+ h.fetcher.start();
263
+ // Send malformed JSON in the response.
264
+ h.respondIos(h.lastRequestId()!, { json: 'not-json{' });
265
+ await vi.runOnlyPendingTimersAsync();
266
+ expect(h.fetcher.getStatus()).toBe('error');
267
+
268
+ // Recover with a valid response.
269
+ await vi.advanceTimersByTimeAsync(500);
270
+ h.respondIos(h.lastRequestId()!);
271
+ await vi.runOnlyPendingTimersAsync();
272
+ expect(h.fetcher.getStatus()).toBe('ready');
273
+ });
274
+ });
275
+
276
+ describe('AxFetcher: bumpActivity / boost window', () => {
277
+ test('bumpActivity is a no-op when not running', () => {
278
+ const h = makeIosHarness();
279
+ h.fetcher.bumpActivity();
280
+ expect(h.send).toHaveBeenCalledTimes(0);
281
+ });
282
+
283
+ test('boosted cadence (~250 ms) is used while in the boost window', async () => {
284
+ const h = makeIosHarness({ baseIntervalMs: 500 });
285
+ h.fetcher.start();
286
+ expect(h.send).toHaveBeenCalledTimes(1);
287
+
288
+ // Bump while the initial fetch is still inflight. Bump can't trigger
289
+ // its own immediate send (inflight is true) but must extend the boost
290
+ // window so the NEXT scheduled fetch lands at ~250 ms rather than the
291
+ // base 500 ms.
292
+ h.fetcher.bumpActivity();
293
+ expect(h.send).toHaveBeenCalledTimes(1);
294
+
295
+ // Resolve the inflight — deliver+scheduleNext run on microtask. Flush
296
+ // microtasks without advancing the clock so scheduling happens but no
297
+ // timer has fired yet.
298
+ h.respondIos(h.lastRequestId()!);
299
+ await Promise.resolve();
300
+ await Promise.resolve();
301
+ await Promise.resolve();
302
+
303
+ // At t = 0+ε, no boosted timer has fired yet (it's scheduled at 250ms).
304
+ expect(h.send).toHaveBeenCalledTimes(1);
305
+
306
+ // At t = 240 ms, still under the boosted interval — no fire.
307
+ await vi.advanceTimersByTimeAsync(240);
308
+ expect(h.send).toHaveBeenCalledTimes(1);
309
+
310
+ // At t = 260 ms (past 250ms), the boosted timer fires.
311
+ await vi.advanceTimersByTimeAsync(30);
312
+ expect(h.send).toHaveBeenCalledTimes(2);
313
+ });
314
+
315
+ test('outside the boost window, cadence returns to base interval', async () => {
316
+ const h = makeIosHarness({ baseIntervalMs: 500 });
317
+ h.fetcher.start();
318
+ h.respondIos(h.lastRequestId()!);
319
+ await Promise.resolve();
320
+ await Promise.resolve();
321
+ await Promise.resolve();
322
+
323
+ // No bumpActivity was called, so we're not in boost. Wait 240 ms — no
324
+ // fire yet (base interval is 500ms).
325
+ await vi.advanceTimersByTimeAsync(240);
326
+ expect(h.send).toHaveBeenCalledTimes(1);
327
+
328
+ // Wait another 280ms to clear 500ms — base-interval fetch fires.
329
+ await vi.advanceTimersByTimeAsync(280);
330
+ expect(h.send).toHaveBeenCalledTimes(2);
331
+ });
332
+ });
333
+
334
+ describe('AxFetcher: handleMessage routing', () => {
335
+ test('iOS handleMessage ignores wrong-platform message shapes', () => {
336
+ const h = makeIosHarness();
337
+ h.fetcher.start();
338
+ const consumed = h.fetcher.handleMessage({
339
+ type: 'getElementTreeResult', // wrong for iOS
340
+ id: h.lastRequestId()!,
341
+ payload: { nodes: [] },
342
+ });
343
+ expect(consumed).toBe(false);
344
+ // Cleanup: respond properly so we don't leave a pending timer.
345
+ h.respondIos(h.lastRequestId()!);
346
+ });
347
+
348
+ test('Android handleMessage parses payload.nodes', async () => {
349
+ const h = makeAndroidHarness();
350
+ h.fetcher.start();
351
+ h.respondAndroid(h.lastRequestId()!);
352
+ await vi.runOnlyPendingTimersAsync();
353
+ expect(h.onSnapshot).toHaveBeenCalledTimes(1);
354
+ const snap = h.onSnapshot.mock.calls[0]![0] as AxSnapshot;
355
+ expect(snap.platform).toBe('android');
356
+ });
357
+
358
+ test('returns false for unknown request ids', () => {
359
+ const h = makeIosHarness();
360
+ h.fetcher.start();
361
+ expect(h.fetcher.handleMessage({ type: 'elementTreeResult', id: 'nope' })).toBe(false);
362
+ });
363
+ });
364
+
365
+ describe('AxFetcher: refresh()', () => {
366
+ test('triggers a one-shot fetch and dedupes via change-detect', async () => {
367
+ const h = makeIosHarness({ baseIntervalMs: 10_000 }); // long base, no spontaneous polls
368
+ h.fetcher.start();
369
+ h.respondIos(h.lastRequestId()!);
370
+ // Flush microtasks without advancing time so the next poll isn't tripped.
371
+ await Promise.resolve();
372
+ await Promise.resolve();
373
+ await Promise.resolve();
374
+ expect(h.onSnapshot).toHaveBeenCalledTimes(1);
375
+ const sendsBefore = h.send.mock.calls.length;
376
+
377
+ // Call refresh — initiates another fetch.
378
+ const p = h.fetcher.refresh();
379
+ expect(h.send.mock.calls.length).toBe(sendsBefore + 1);
380
+ h.respondIos(h.lastRequestId()!);
381
+ const snapshot = await p;
382
+ expect(snapshot.platform).toBe('ios');
383
+ // Identical content → no extra onSnapshot.
384
+ expect(h.onSnapshot).toHaveBeenCalledTimes(1);
385
+ });
386
+ });
387
+
388
+ describe('AxFetcher: status callback safety', () => {
389
+ test('a throw in onStatusChange does not break the state machine', async () => {
390
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
391
+ try {
392
+ const onStatusChange = vi.fn(() => {
393
+ throw new Error('boom');
394
+ });
395
+ const send = vi.fn(() => true);
396
+ const onSnapshot = vi.fn();
397
+ const fetcher = new AxFetcher({
398
+ platform: 'ios',
399
+ send,
400
+ onSnapshot,
401
+ onStatusChange: onStatusChange as unknown as (s: AxStatus, e?: string) => void,
402
+ });
403
+ fetcher.start();
404
+ expect(fetcher.getStatus()).toBe('starting');
405
+ // Calling stop() would re-trigger the throwing callback — must not throw.
406
+ expect(() => fetcher.stop()).not.toThrow();
407
+
408
+ // The inflight request will time out asynchronously and call
409
+ // setStatus('error'), which also goes through the throwing callback.
410
+ // Drain that path while the spy is still in place to keep stderr
411
+ // clean for the rest of the suite.
412
+ await vi.advanceTimersByTimeAsync(10_000);
413
+ expect(consoleSpy).toHaveBeenCalled();
414
+ } finally {
415
+ consoleSpy.mockRestore();
416
+ }
417
+ });
418
+ });