@limrun/ui 0.9.0-rc.4 → 0.9.0-rc.5
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/README.md +0 -9
- 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 -2
- package/dist/index.js +1491 -777
- package/package.json +8 -17
- 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 -2
- package/vite.config.ts +2 -6
- package/vitest.config.ts +23 -0
- package/dist/components/device-install/device-install-dialog.d.ts +0 -5
- package/dist/components/device-install/index.d.ts +0 -2
- package/dist/core/device-install/apple/client.d.ts +0 -17
- package/dist/core/device-install/apple/crypto.d.ts +0 -20
- package/dist/core/device-install/apple/gsa-srp.d.ts +0 -26
- package/dist/core/device-install/apple/index.d.ts +0 -5
- package/dist/core/device-install/apple/provisioning.d.ts +0 -161
- package/dist/core/device-install/apple/relay.d.ts +0 -29
- package/dist/core/device-install/index.d.ts +0 -4
- package/dist/core/device-install/operations/index.d.ts +0 -6
- package/dist/core/device-install/operations/limbuild-client.d.ts +0 -28
- package/dist/core/device-install/operations/operations.d.ts +0 -32
- package/dist/core/device-install/operations/relay-client.d.ts +0 -25
- package/dist/core/device-install/operations/relay-protocol.d.ts +0 -27
- package/dist/core/device-install/operations/usbmux.d.ts +0 -32
- package/dist/core/device-install/operations/webusb.d.ts +0 -21
- package/dist/core/device-install/storage/browser-storage.d.ts +0 -44
- package/dist/core/device-install/storage/index.d.ts +0 -1
- package/dist/core/device-install/types.d.ts +0 -48
- package/dist/device-install/index.cjs +0 -1
- package/dist/device-install/index.d.ts +0 -3
- package/dist/device-install/index.js +0 -78
- package/dist/device-install/react.cjs +0 -1
- package/dist/device-install/react.d.ts +0 -1
- package/dist/device-install/react.js +0 -4
- package/dist/device-install-dialog-86RDdoK9.js +0 -2
- package/dist/device-install-dialog-CnyDWf0q.mjs +0 -462
- package/dist/device-install-dialog.css +0 -1
- package/dist/hooks/index.d.ts +0 -1
- package/dist/hooks/use-device-install.d.ts +0 -73
- package/dist/use-device-install-CbGVvwPp.js +0 -31
- package/dist/use-device-install-j1Gekpl4.mjs +0 -13623
- package/src/components/device-install/device-install-dialog.css +0 -325
- package/src/components/device-install/device-install-dialog.tsx +0 -513
- package/src/components/device-install/index.ts +0 -2
- package/src/core/device-install/apple/client.ts +0 -152
- package/src/core/device-install/apple/crypto.ts +0 -202
- package/src/core/device-install/apple/gsa-srp.ts +0 -127
- package/src/core/device-install/apple/index.ts +0 -5
- package/src/core/device-install/apple/provisioning.ts +0 -298
- package/src/core/device-install/apple/relay.ts +0 -221
- package/src/core/device-install/index.ts +0 -4
- package/src/core/device-install/operations/index.ts +0 -6
- package/src/core/device-install/operations/limbuild-client.ts +0 -104
- package/src/core/device-install/operations/operations.ts +0 -217
- package/src/core/device-install/operations/relay-client.ts +0 -255
- package/src/core/device-install/operations/relay-protocol.ts +0 -71
- package/src/core/device-install/operations/usbmux.ts +0 -270
- package/src/core/device-install/operations/webusb-dom.d.ts +0 -54
- package/src/core/device-install/operations/webusb.ts +0 -105
- package/src/core/device-install/storage/browser-storage.ts +0 -263
- package/src/core/device-install/storage/index.ts +0 -1
- package/src/core/device-install/types.ts +0 -65
- package/src/device-install/index.ts +0 -3
- package/src/device-install/react.ts +0 -1
- package/src/hooks/index.ts +0 -1
- package/src/hooks/use-device-install.ts +0 -1210
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
//
|
|
3
|
+
// Tests for `core/ax-tree.ts` — pure normalizers + helpers, no DOM required.
|
|
4
|
+
//
|
|
5
|
+
// These cover the contract customers depend on through `onAxSnapshotChange`
|
|
6
|
+
// and the exported helpers, so regressions here surface as silent overlay
|
|
7
|
+
// drift or selector copy-paste pain.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'vitest';
|
|
10
|
+
import {
|
|
11
|
+
AxElement,
|
|
12
|
+
AxSnapshot,
|
|
13
|
+
AX_UNAVAILABLE_ERROR,
|
|
14
|
+
axElementAtPoint,
|
|
15
|
+
axElementRoleLabel,
|
|
16
|
+
axElementSelectorExpression,
|
|
17
|
+
axElementSummary,
|
|
18
|
+
axElementsEqual,
|
|
19
|
+
axSnapshotsEqual,
|
|
20
|
+
clampAxFrameForScreen,
|
|
21
|
+
normalizeAndroidTree,
|
|
22
|
+
normalizeIosTree,
|
|
23
|
+
} from './ax-tree';
|
|
24
|
+
|
|
25
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// normalizeIosTree
|
|
27
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe('normalizeIosTree', () => {
|
|
30
|
+
test('flattens nested children, skipping the screen-sized root', () => {
|
|
31
|
+
const tree = [
|
|
32
|
+
{
|
|
33
|
+
// root = whole screen, should be filtered
|
|
34
|
+
frame: { x: 0, y: 0, width: 393, height: 852 },
|
|
35
|
+
type: 'Application',
|
|
36
|
+
children: [
|
|
37
|
+
{
|
|
38
|
+
frame: { x: 16, y: 64, width: 200, height: 40 },
|
|
39
|
+
type: 'Button',
|
|
40
|
+
AXLabel: 'Sign in',
|
|
41
|
+
AXUniqueId: 'signin-button',
|
|
42
|
+
children: [
|
|
43
|
+
{
|
|
44
|
+
frame: { x: 24, y: 70, width: 80, height: 20 },
|
|
45
|
+
type: 'StaticText',
|
|
46
|
+
AXLabel: 'Sign in',
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const snap = normalizeIosTree(tree);
|
|
55
|
+
|
|
56
|
+
expect(snap.platform).toBe('ios');
|
|
57
|
+
expect(snap.screen).toEqual({ width: 393, height: 852 });
|
|
58
|
+
// Application (root, screen-sized) skipped; button + nested text kept.
|
|
59
|
+
expect(snap.elements).toHaveLength(2);
|
|
60
|
+
expect(snap.elements[0].label).toBe('Sign in');
|
|
61
|
+
expect(snap.elements[0].id).toBe('signin-button');
|
|
62
|
+
expect(snap.elements[0].selectors.AXUniqueId).toBe('signin-button');
|
|
63
|
+
expect(snap.elements[1].path).toBe('0.0.0');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('drops zero-area frames', () => {
|
|
67
|
+
const tree = [
|
|
68
|
+
{
|
|
69
|
+
frame: { x: 0, y: 0, width: 400, height: 800 },
|
|
70
|
+
type: 'Application',
|
|
71
|
+
children: [
|
|
72
|
+
{ frame: { x: 0, y: 0, width: 0, height: 50 }, type: 'Invisible' },
|
|
73
|
+
{ frame: { x: 0, y: 0, width: 50, height: 0 }, type: 'Invisible' },
|
|
74
|
+
{ frame: { x: -10, y: 10, width: -5, height: 10 }, type: 'Invisible' },
|
|
75
|
+
{ frame: { x: 10, y: 10, width: 50, height: 50 }, type: 'Visible' },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
const snap = normalizeIosTree(tree);
|
|
80
|
+
expect(snap.elements).toHaveLength(1);
|
|
81
|
+
expect(snap.elements[0].type).toBe('Visible');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('caps at MAX_ELEMENTS (500) even on huge trees', () => {
|
|
85
|
+
const children = Array.from({ length: 1000 }, (_, i) => ({
|
|
86
|
+
frame: { x: i, y: 0, width: 1, height: 1 },
|
|
87
|
+
type: 'T',
|
|
88
|
+
}));
|
|
89
|
+
const snap = normalizeIosTree([
|
|
90
|
+
{
|
|
91
|
+
frame: { x: 0, y: 0, width: 1000, height: 1 },
|
|
92
|
+
type: 'Root',
|
|
93
|
+
children,
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
expect(snap.elements.length).toBeLessThanOrEqual(500);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('falls back to path id when AXUniqueId is missing', () => {
|
|
100
|
+
const snap = normalizeIosTree([
|
|
101
|
+
{
|
|
102
|
+
frame: { x: 0, y: 0, width: 400, height: 800 },
|
|
103
|
+
type: 'Application',
|
|
104
|
+
children: [
|
|
105
|
+
{ frame: { x: 0, y: 0, width: 50, height: 50 }, type: 'A' },
|
|
106
|
+
{ frame: { x: 0, y: 0, width: 50, height: 50 }, type: 'B', AXUniqueId: 'b-id' },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
expect(snap.elements[0].id).toBe('0.0');
|
|
111
|
+
expect(snap.elements[1].id).toBe('b-id');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('accepts a single-object root (not an array)', () => {
|
|
115
|
+
const snap = normalizeIosTree({
|
|
116
|
+
frame: { x: 0, y: 0, width: 200, height: 400 },
|
|
117
|
+
type: 'Application',
|
|
118
|
+
children: [{ frame: { x: 10, y: 10, width: 30, height: 30 }, type: 'X' }],
|
|
119
|
+
});
|
|
120
|
+
expect(snap.elements).toHaveLength(1);
|
|
121
|
+
expect(snap.screen).toEqual({ width: 200, height: 400 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('returns a usable empty snapshot for an empty input', () => {
|
|
125
|
+
const snap = normalizeIosTree([]);
|
|
126
|
+
expect(snap.elements).toEqual([]);
|
|
127
|
+
expect(snap.platform).toBe('ios');
|
|
128
|
+
expect(snap.screen.width).toBeGreaterThan(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('strips children from `raw` to avoid retaining the whole subtree', () => {
|
|
132
|
+
const snap = normalizeIosTree([
|
|
133
|
+
{
|
|
134
|
+
frame: { x: 0, y: 0, width: 400, height: 800 },
|
|
135
|
+
children: [
|
|
136
|
+
{
|
|
137
|
+
frame: { x: 0, y: 0, width: 50, height: 50 },
|
|
138
|
+
type: 'A',
|
|
139
|
+
AXLabel: 'A',
|
|
140
|
+
children: [{ frame: { x: 0, y: 0, width: 25, height: 25 }, type: 'A-Child' }],
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
const a = snap.elements.find((e) => e.label === 'A')!;
|
|
146
|
+
expect(a.raw).not.toHaveProperty('children');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
151
|
+
// normalizeAndroidTree
|
|
152
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe('normalizeAndroidTree', () => {
|
|
155
|
+
const mkNode = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
|
156
|
+
resourceId: '',
|
|
157
|
+
text: '',
|
|
158
|
+
contentDesc: '',
|
|
159
|
+
className: 'android.widget.View',
|
|
160
|
+
parsedBounds: { left: 0, top: 0, right: 50, bottom: 50, centerX: 25, centerY: 25 },
|
|
161
|
+
enabled: true,
|
|
162
|
+
...overrides,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('uses the largest rect as the screen frame', () => {
|
|
166
|
+
const nodes = [
|
|
167
|
+
// not the largest — should not be picked
|
|
168
|
+
{ parsedBounds: { left: 0, top: 0, right: 100, bottom: 100, centerX: 50, centerY: 50 } },
|
|
169
|
+
// largest — screen
|
|
170
|
+
{
|
|
171
|
+
parsedBounds: { left: 0, top: 0, right: 1080, bottom: 2400, centerX: 540, centerY: 1200 },
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
const snap = normalizeAndroidTree(nodes);
|
|
175
|
+
expect(snap.screen).toEqual({ width: 1080, height: 2400 });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('drops the screen-sized rect and keeps children', () => {
|
|
179
|
+
const nodes = [
|
|
180
|
+
// screen
|
|
181
|
+
{
|
|
182
|
+
className: 'android.widget.FrameLayout',
|
|
183
|
+
parsedBounds: { left: 0, top: 0, right: 1080, bottom: 2400, centerX: 540, centerY: 1200 },
|
|
184
|
+
},
|
|
185
|
+
mkNode({
|
|
186
|
+
resourceId: 'btn-home',
|
|
187
|
+
contentDesc: 'Home',
|
|
188
|
+
parsedBounds: {
|
|
189
|
+
left: 100,
|
|
190
|
+
top: 2300,
|
|
191
|
+
right: 200,
|
|
192
|
+
bottom: 2400,
|
|
193
|
+
centerX: 150,
|
|
194
|
+
centerY: 2350,
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
];
|
|
198
|
+
const snap = normalizeAndroidTree(nodes);
|
|
199
|
+
expect(snap.elements.map((e) => e.id)).toEqual(['btn-home']);
|
|
200
|
+
expect(snap.elements[0].label).toBe('Home');
|
|
201
|
+
expect(snap.elements[0].selectors.resourceId).toBe('btn-home');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('synthesizes a `cd:` id when resourceId is missing but contentDesc is present', () => {
|
|
205
|
+
const nodes = [
|
|
206
|
+
{
|
|
207
|
+
parsedBounds: { left: 0, top: 0, right: 1080, bottom: 2400, centerX: 540, centerY: 1200 },
|
|
208
|
+
},
|
|
209
|
+
mkNode({
|
|
210
|
+
contentDesc: 'Settings',
|
|
211
|
+
parsedBounds: {
|
|
212
|
+
left: 10,
|
|
213
|
+
top: 10,
|
|
214
|
+
right: 60,
|
|
215
|
+
bottom: 60,
|
|
216
|
+
centerX: 35,
|
|
217
|
+
centerY: 35,
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
];
|
|
221
|
+
const snap = normalizeAndroidTree(nodes);
|
|
222
|
+
expect(snap.elements[0].id).toBe('cd:Settings');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('skips nodes with no parsedBounds or zero area', () => {
|
|
226
|
+
const nodes = [
|
|
227
|
+
{
|
|
228
|
+
parsedBounds: { left: 0, top: 0, right: 1080, bottom: 2400, centerX: 540, centerY: 1200 },
|
|
229
|
+
},
|
|
230
|
+
mkNode({ parsedBounds: undefined }),
|
|
231
|
+
mkNode({
|
|
232
|
+
parsedBounds: { left: 10, top: 10, right: 10, bottom: 50, centerX: 10, centerY: 30 },
|
|
233
|
+
}),
|
|
234
|
+
];
|
|
235
|
+
const snap = normalizeAndroidTree(nodes);
|
|
236
|
+
expect(snap.elements).toHaveLength(0);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
// helpers
|
|
242
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
describe('clampAxFrameForScreen', () => {
|
|
245
|
+
const screen = { width: 100, height: 100 };
|
|
246
|
+
|
|
247
|
+
test('returns the frame unchanged when fully inside', () => {
|
|
248
|
+
expect(clampAxFrameForScreen({ x: 10, y: 10, width: 50, height: 50 }, screen)).toEqual({
|
|
249
|
+
x: 10,
|
|
250
|
+
y: 10,
|
|
251
|
+
width: 50,
|
|
252
|
+
height: 50,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('clips a frame that overhangs the right/bottom edges', () => {
|
|
257
|
+
expect(clampAxFrameForScreen({ x: 80, y: 80, width: 40, height: 40 }, screen)).toEqual({
|
|
258
|
+
x: 80,
|
|
259
|
+
y: 80,
|
|
260
|
+
width: 20,
|
|
261
|
+
height: 20,
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('clamps negative x/y to zero and reduces width/height accordingly', () => {
|
|
266
|
+
expect(clampAxFrameForScreen({ x: -10, y: -20, width: 30, height: 30 }, screen)).toEqual({
|
|
267
|
+
x: 0,
|
|
268
|
+
y: 0,
|
|
269
|
+
width: 20,
|
|
270
|
+
height: 10,
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('returns null when the visible area becomes empty', () => {
|
|
275
|
+
expect(clampAxFrameForScreen({ x: 200, y: 200, width: 10, height: 10 }, screen)).toBeNull();
|
|
276
|
+
expect(clampAxFrameForScreen({ x: 0, y: 0, width: 0, height: 100 }, screen)).toBeNull();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('axElementAtPoint', () => {
|
|
281
|
+
const mkEl = (id: string, x: number, y: number, w: number, h: number): AxElement => ({
|
|
282
|
+
id,
|
|
283
|
+
path: id,
|
|
284
|
+
label: id,
|
|
285
|
+
value: '',
|
|
286
|
+
role: '',
|
|
287
|
+
type: '',
|
|
288
|
+
enabled: true,
|
|
289
|
+
focused: false,
|
|
290
|
+
frame: { x, y, width: w, height: h },
|
|
291
|
+
selectors: {},
|
|
292
|
+
raw: {},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const snap: AxSnapshot = {
|
|
296
|
+
platform: 'ios',
|
|
297
|
+
screen: { width: 1000, height: 1000 },
|
|
298
|
+
elements: [
|
|
299
|
+
mkEl('outer', 0, 0, 1000, 1000),
|
|
300
|
+
mkEl('mid', 100, 100, 800, 800),
|
|
301
|
+
mkEl('inner', 400, 400, 200, 200),
|
|
302
|
+
],
|
|
303
|
+
capturedAt: 0,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
test('picks the smallest element under the point', () => {
|
|
307
|
+
expect(axElementAtPoint(snap, 500, 500)?.id).toBe('inner');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('falls back to a larger element when the smaller one does not contain the point', () => {
|
|
311
|
+
expect(axElementAtPoint(snap, 200, 200)?.id).toBe('mid');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('returns null for points outside all rects', () => {
|
|
315
|
+
expect(axElementAtPoint(snap, -10, -10)).toBeNull();
|
|
316
|
+
expect(axElementAtPoint(snap, 2000, 2000)).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('boundary point on the right/bottom edge is considered inside', () => {
|
|
320
|
+
// 600, 600 = exactly the right/bottom edge of `inner` (400+200, 400+200)
|
|
321
|
+
expect(axElementAtPoint(snap, 600, 600)?.id).toBe('inner');
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe('axElementsEqual & axSnapshotsEqual', () => {
|
|
326
|
+
const mkEl = (id: string, frame: { x: number; y: number; width: number; height: number }): AxElement => ({
|
|
327
|
+
id,
|
|
328
|
+
path: id,
|
|
329
|
+
label: 'L',
|
|
330
|
+
value: '',
|
|
331
|
+
role: 'r',
|
|
332
|
+
type: 't',
|
|
333
|
+
enabled: true,
|
|
334
|
+
focused: false,
|
|
335
|
+
frame,
|
|
336
|
+
selectors: {},
|
|
337
|
+
raw: {},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('axElementsEqual returns true for content-identical elements', () => {
|
|
341
|
+
expect(
|
|
342
|
+
axElementsEqual(
|
|
343
|
+
mkEl('a', { x: 0, y: 0, width: 1, height: 1 }),
|
|
344
|
+
mkEl('a', { x: 0, y: 0, width: 1, height: 1 }),
|
|
345
|
+
),
|
|
346
|
+
).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('axElementsEqual returns false on any field difference', () => {
|
|
350
|
+
const a = mkEl('a', { x: 0, y: 0, width: 1, height: 1 });
|
|
351
|
+
expect(axElementsEqual(a, { ...a, frame: { ...a.frame, x: 1 } })).toBe(false);
|
|
352
|
+
expect(axElementsEqual(a, { ...a, label: 'X' })).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('axSnapshotsEqual compares element lists in order', () => {
|
|
356
|
+
const e1 = mkEl('a', { x: 0, y: 0, width: 1, height: 1 });
|
|
357
|
+
const e2 = mkEl('b', { x: 1, y: 1, width: 1, height: 1 });
|
|
358
|
+
const s1: AxSnapshot = {
|
|
359
|
+
platform: 'ios',
|
|
360
|
+
screen: { width: 10, height: 10 },
|
|
361
|
+
elements: [e1, e2],
|
|
362
|
+
capturedAt: 100,
|
|
363
|
+
};
|
|
364
|
+
const s2: AxSnapshot = { ...s1, capturedAt: 200 };
|
|
365
|
+
// capturedAt is metadata, not content — equal.
|
|
366
|
+
expect(axSnapshotsEqual(s1, s2)).toBe(true);
|
|
367
|
+
|
|
368
|
+
const s3: AxSnapshot = { ...s1, elements: [e2, e1] };
|
|
369
|
+
expect(axSnapshotsEqual(s1, s3)).toBe(false);
|
|
370
|
+
|
|
371
|
+
expect(axSnapshotsEqual(null, null)).toBe(true);
|
|
372
|
+
expect(axSnapshotsEqual(null, s1)).toBe(false);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe('axElementRoleLabel', () => {
|
|
377
|
+
const mk = (role: string, type = ''): AxElement => ({
|
|
378
|
+
id: '0',
|
|
379
|
+
path: '0',
|
|
380
|
+
label: '',
|
|
381
|
+
value: '',
|
|
382
|
+
role,
|
|
383
|
+
type,
|
|
384
|
+
enabled: true,
|
|
385
|
+
focused: false,
|
|
386
|
+
frame: { x: 0, y: 0, width: 1, height: 1 },
|
|
387
|
+
selectors: {},
|
|
388
|
+
raw: {},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('strips AX prefix and splits CamelCase', () => {
|
|
392
|
+
expect(axElementRoleLabel(mk('AXTextField'))).toBe('Text Field');
|
|
393
|
+
expect(axElementRoleLabel(mk('AXButton'))).toBe('Button');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('rewrites GenericElement to "Element"', () => {
|
|
397
|
+
expect(axElementRoleLabel(mk('AXGenericElement'))).toBe('Element');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('reduces fully-qualified Android class names to the last segment and humanizes', () => {
|
|
401
|
+
expect(axElementRoleLabel(mk('android.widget.TextView'))).toBe('Text View');
|
|
402
|
+
expect(axElementRoleLabel(mk('android.widget.FrameLayout'))).toBe('Frame Layout');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('falls back to "element" for empty role/type', () => {
|
|
406
|
+
expect(axElementRoleLabel(mk('', ''))).toBe('element');
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
describe('axElementSummary', () => {
|
|
411
|
+
const mk = (label: string, role = 'Button'): AxElement => ({
|
|
412
|
+
id: '0',
|
|
413
|
+
path: '0',
|
|
414
|
+
label,
|
|
415
|
+
value: '',
|
|
416
|
+
role,
|
|
417
|
+
type: '',
|
|
418
|
+
enabled: true,
|
|
419
|
+
focused: false,
|
|
420
|
+
frame: { x: 0, y: 0, width: 1, height: 1 },
|
|
421
|
+
selectors: {},
|
|
422
|
+
raw: {},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('concatenates role and label', () => {
|
|
426
|
+
expect(axElementSummary(mk('Sign in'))).toBe('Button · Sign in');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('truncates long labels with ellipsis', () => {
|
|
430
|
+
const long = 'a'.repeat(120);
|
|
431
|
+
const summary = axElementSummary(mk(long));
|
|
432
|
+
expect(summary.length).toBeLessThan(120);
|
|
433
|
+
expect(summary).toMatch(/…$/);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('drops label when empty', () => {
|
|
437
|
+
expect(axElementSummary(mk(''))).toBe('Button');
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe('axElementSelectorExpression', () => {
|
|
442
|
+
const mkIos = (sel: Partial<AxElement['selectors']>): AxElement => ({
|
|
443
|
+
id: '0',
|
|
444
|
+
path: '0',
|
|
445
|
+
label: '',
|
|
446
|
+
value: '',
|
|
447
|
+
role: '',
|
|
448
|
+
type: '',
|
|
449
|
+
enabled: true,
|
|
450
|
+
focused: false,
|
|
451
|
+
frame: { x: 0, y: 0, width: 1, height: 1 },
|
|
452
|
+
selectors: sel,
|
|
453
|
+
raw: {},
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('iOS: prefers AXUniqueId', () => {
|
|
457
|
+
expect(axElementSelectorExpression(mkIos({ AXUniqueId: 'signin' }), 'ios')).toBe(
|
|
458
|
+
'client.tapElement({ AXUniqueId: "signin" })',
|
|
459
|
+
);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('iOS: falls back to AXLabel + type hint', () => {
|
|
463
|
+
expect(axElementSelectorExpression(mkIos({ AXLabel: 'OK', className: 'Button' }), 'ios')).toBe(
|
|
464
|
+
'client.tapElement({ AXLabel: "OK", type: "Button" })',
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('iOS: returns null when no selectors are usable', () => {
|
|
469
|
+
expect(axElementSelectorExpression(mkIos({}), 'ios')).toBeNull();
|
|
470
|
+
expect(axElementSelectorExpression(mkIos({ className: 'OnlyClass' }), 'ios')).toBeNull();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test('Android: prefers resourceId, then contentDesc, then text', () => {
|
|
474
|
+
expect(axElementSelectorExpression(mkIos({ resourceId: 'r1' }), 'android')).toMatch(/resourceId/);
|
|
475
|
+
expect(axElementSelectorExpression(mkIos({ contentDesc: 'cd' }), 'android')).toMatch(/contentDesc/);
|
|
476
|
+
expect(axElementSelectorExpression(mkIos({ text: 't' }), 'android')).toMatch(/text/);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('escapes quotes in selector values via JSON.stringify', () => {
|
|
480
|
+
expect(axElementSelectorExpression(mkIos({ AXLabel: 'has "quotes"' }), 'ios')).toContain(
|
|
481
|
+
'AXLabel: "has \\"quotes\\""',
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
describe('AX_UNAVAILABLE_ERROR', () => {
|
|
487
|
+
test('is a non-empty string customers can compare against', () => {
|
|
488
|
+
expect(typeof AX_UNAVAILABLE_ERROR).toBe('string');
|
|
489
|
+
expect(AX_UNAVAILABLE_ERROR.length).toBeGreaterThan(0);
|
|
490
|
+
});
|
|
491
|
+
});
|