@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,416 @@
|
|
|
1
|
+
// Accessibility tree types, normalizers, and helpers shared by the inspect
|
|
2
|
+
// overlay and exported for customers building their own panels.
|
|
3
|
+
//
|
|
4
|
+
// We unify two server response shapes into a single AxSnapshot:
|
|
5
|
+
//
|
|
6
|
+
// iOS (limulator): {type:'elementTreeResult', id, json: '<nested-tree-json>'}
|
|
7
|
+
// Android (scrcpy): {type:'getElementTreeResult', id, payload:{nodes:[flat]}}
|
|
8
|
+
// (also emits legacy {type:'elementTreeResult', id, json:'<xml>'})
|
|
9
|
+
//
|
|
10
|
+
// Both are flattened into a single list of AxElement; positions are expressed
|
|
11
|
+
// in a normalized screen coordinate space derived from the root rect so
|
|
12
|
+
// rendering can use plain percentages.
|
|
13
|
+
|
|
14
|
+
export interface AxRect {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AxSelectors {
|
|
22
|
+
AXUniqueId?: string;
|
|
23
|
+
AXLabel?: string;
|
|
24
|
+
resourceId?: string;
|
|
25
|
+
contentDesc?: string;
|
|
26
|
+
text?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AxElement {
|
|
31
|
+
// Stable identity for React keys / selection persistence across snapshots.
|
|
32
|
+
// Prefers AXUniqueId / resourceId, falls back to a hierarchical path.
|
|
33
|
+
id: string;
|
|
34
|
+
// Hierarchical path within the source tree (e.g. "0.1.2"). Useful as a
|
|
35
|
+
// fallback identity and for debugging.
|
|
36
|
+
path: string;
|
|
37
|
+
// Human label (AXLabel on iOS, content-desc/text on Android).
|
|
38
|
+
label: string;
|
|
39
|
+
// Value (AXValue on iOS, text on Android inputs).
|
|
40
|
+
value: string;
|
|
41
|
+
// Semantic role (role_description on iOS, className on Android).
|
|
42
|
+
role: string;
|
|
43
|
+
// Element type / class name.
|
|
44
|
+
type: string;
|
|
45
|
+
// Whether the element is interactive.
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
// Whether the element currently has focus.
|
|
48
|
+
focused: boolean;
|
|
49
|
+
// Bounds in the screen coordinate space of AxSnapshot.screen.
|
|
50
|
+
frame: AxRect;
|
|
51
|
+
// Selectors that map back to SDK tapElement / tap calls.
|
|
52
|
+
selectors: AxSelectors;
|
|
53
|
+
// Raw platform-specific node (without children/parsedBounds extras).
|
|
54
|
+
// Exposed so advanced customers can read fields we didn't surface.
|
|
55
|
+
raw: Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type AxPlatform = 'ios' | 'android';
|
|
59
|
+
|
|
60
|
+
export interface AxSnapshot {
|
|
61
|
+
platform: AxPlatform;
|
|
62
|
+
screen: { width: number; height: number };
|
|
63
|
+
elements: AxElement[];
|
|
64
|
+
// Unix epoch ms when the response was decoded on the client.
|
|
65
|
+
capturedAt: number;
|
|
66
|
+
errors?: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const AX_UNAVAILABLE_ERROR = 'Accessibility unavailable on this device.';
|
|
70
|
+
|
|
71
|
+
// Hard cap to keep React render time bounded on enormous trees.
|
|
72
|
+
const MAX_ELEMENTS = 500;
|
|
73
|
+
|
|
74
|
+
const rectsApproxEqual = (a: AxRect, b: AxRect): boolean =>
|
|
75
|
+
Math.abs(a.x - b.x) < 0.5 &&
|
|
76
|
+
Math.abs(a.y - b.y) < 0.5 &&
|
|
77
|
+
Math.abs(a.width - b.width) < 0.5 &&
|
|
78
|
+
Math.abs(a.height - b.height) < 0.5;
|
|
79
|
+
|
|
80
|
+
const rectArea = (r: AxRect): number => Math.max(0, r.width) * Math.max(0, r.height);
|
|
81
|
+
|
|
82
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
// iOS normalization
|
|
84
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
interface RawIosNode {
|
|
87
|
+
AXLabel?: string | null;
|
|
88
|
+
AXValue?: string | null;
|
|
89
|
+
AXUniqueId?: string | null;
|
|
90
|
+
// `frame` is the canonical bounds; the legacy `AXFrame` string field is
|
|
91
|
+
// not consumed.
|
|
92
|
+
frame?: { x: number; y: number; width: number; height: number };
|
|
93
|
+
role?: string;
|
|
94
|
+
role_description?: string;
|
|
95
|
+
type?: string;
|
|
96
|
+
subrole?: string | null;
|
|
97
|
+
title?: string | null;
|
|
98
|
+
enabled?: boolean;
|
|
99
|
+
focused?: boolean;
|
|
100
|
+
pid?: number;
|
|
101
|
+
traits?: string[];
|
|
102
|
+
children?: RawIosNode[];
|
|
103
|
+
// Some serializations carry extras we'll preserve in `raw`.
|
|
104
|
+
[key: string]: unknown;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Picks the "screen rectangle" used as the denominator when expressing
|
|
108
|
+
// element positions as percentages.
|
|
109
|
+
//
|
|
110
|
+
// Apple's `accessibilityElementForFrontmostApplication()` returns the
|
|
111
|
+
// foreground Application element as the (only) root; its `frame` is the
|
|
112
|
+
// device's logical screen — `{x: 0, y: 0, width, height}` in points. The
|
|
113
|
+
// element frames inside the tree are in this same coordinate space, so a
|
|
114
|
+
// child at `(16, 64)` is 16pt from the device's left edge and 64pt from
|
|
115
|
+
// the top edge.
|
|
116
|
+
//
|
|
117
|
+
// We accept any root whose frame has positive dimensions to be robust
|
|
118
|
+
// against the edge case where the foreground app reports a non-zero
|
|
119
|
+
// origin (e.g. status bar excluded). In practice that never happens; if
|
|
120
|
+
// it does, percentages will be slightly off but the overlay will still
|
|
121
|
+
// roughly line up. The defensive console.warn helps us catch this in
|
|
122
|
+
// production telemetry without breaking the feature.
|
|
123
|
+
const iosScreenFrame = (roots: RawIosNode[]): AxRect => {
|
|
124
|
+
const root = roots.find((n) => n.frame && n.frame.width > 0 && n.frame.height > 0);
|
|
125
|
+
if (root?.frame) {
|
|
126
|
+
if (Math.abs(root.frame.x) > 0.5 || Math.abs(root.frame.y) > 0.5) {
|
|
127
|
+
console.warn(
|
|
128
|
+
`[ax-tree] iOS root frame is not anchored at (0,0): ` +
|
|
129
|
+
`(${root.frame.x},${root.frame.y}). Element positions may be ` +
|
|
130
|
+
`slightly off from the rendered video.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return { x: 0, y: 0, width: root.frame.width, height: root.frame.height };
|
|
134
|
+
}
|
|
135
|
+
return { x: 0, y: 0, width: 1, height: 1 };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const buildIosSelectors = (node: RawIosNode): AxSelectors => {
|
|
139
|
+
const sel: AxSelectors = {};
|
|
140
|
+
if (typeof node.AXUniqueId === 'string' && node.AXUniqueId.length > 0) sel.AXUniqueId = node.AXUniqueId;
|
|
141
|
+
if (typeof node.AXLabel === 'string' && node.AXLabel.length > 0) sel.AXLabel = node.AXLabel;
|
|
142
|
+
if (typeof node.type === 'string' && node.type.length > 0) sel.className = node.type;
|
|
143
|
+
return sel;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const stripIosChildren = (node: RawIosNode): Record<string, unknown> => {
|
|
147
|
+
const out: Record<string, unknown> = {};
|
|
148
|
+
for (const [k, v] of Object.entries(node)) {
|
|
149
|
+
if (k === 'children') continue;
|
|
150
|
+
out[k] = v;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export function normalizeIosTree(roots: RawIosNode[] | RawIosNode): AxSnapshot {
|
|
156
|
+
const rootArray = Array.isArray(roots) ? roots : [roots];
|
|
157
|
+
const screen = iosScreenFrame(rootArray);
|
|
158
|
+
const elements: AxElement[] = [];
|
|
159
|
+
|
|
160
|
+
const visit = (node: RawIosNode, path: string) => {
|
|
161
|
+
if (elements.length >= MAX_ELEMENTS) return;
|
|
162
|
+
const frame = node.frame;
|
|
163
|
+
if (frame && frame.width > 0 && frame.height > 0 && !rectsApproxEqual(frame, screen)) {
|
|
164
|
+
const role = (node.role_description as string | undefined) || (node.role as string | undefined) || '';
|
|
165
|
+
const label =
|
|
166
|
+
(typeof node.AXLabel === 'string' ? node.AXLabel : '') ||
|
|
167
|
+
(typeof node.title === 'string' ? (node.title as string) : '') ||
|
|
168
|
+
'';
|
|
169
|
+
elements.push({
|
|
170
|
+
id: typeof node.AXUniqueId === 'string' && node.AXUniqueId.length > 0 ? node.AXUniqueId : path,
|
|
171
|
+
path,
|
|
172
|
+
label,
|
|
173
|
+
value: typeof node.AXValue === 'string' ? node.AXValue : '',
|
|
174
|
+
role,
|
|
175
|
+
type: typeof node.type === 'string' ? node.type : '',
|
|
176
|
+
enabled: node.enabled !== false,
|
|
177
|
+
focused: node.focused === true,
|
|
178
|
+
frame: { x: frame.x, y: frame.y, width: frame.width, height: frame.height },
|
|
179
|
+
selectors: buildIosSelectors(node),
|
|
180
|
+
raw: stripIosChildren(node),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
184
|
+
for (let i = 0; i < children.length && elements.length < MAX_ELEMENTS; i++) {
|
|
185
|
+
visit(children[i]!, `${path}.${i}`);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
for (let i = 0; i < rootArray.length && elements.length < MAX_ELEMENTS; i++) {
|
|
190
|
+
visit(rootArray[i]!, String(i));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
platform: 'ios',
|
|
195
|
+
screen: { width: screen.width, height: screen.height },
|
|
196
|
+
elements,
|
|
197
|
+
capturedAt: Date.now(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
202
|
+
// Android normalization
|
|
203
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
interface RawAndroidParsedBounds {
|
|
206
|
+
left: number;
|
|
207
|
+
top: number;
|
|
208
|
+
right: number;
|
|
209
|
+
bottom: number;
|
|
210
|
+
centerX: number;
|
|
211
|
+
centerY: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface RawAndroidNode {
|
|
215
|
+
index?: string;
|
|
216
|
+
text?: string;
|
|
217
|
+
resourceId?: string;
|
|
218
|
+
className?: string;
|
|
219
|
+
packageName?: string;
|
|
220
|
+
contentDesc?: string;
|
|
221
|
+
clickable?: boolean;
|
|
222
|
+
enabled?: boolean;
|
|
223
|
+
focusable?: boolean;
|
|
224
|
+
focused?: boolean;
|
|
225
|
+
scrollable?: boolean;
|
|
226
|
+
selected?: boolean;
|
|
227
|
+
bounds?: string;
|
|
228
|
+
parsedBounds?: RawAndroidParsedBounds;
|
|
229
|
+
[key: string]: unknown;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const androidScreenFrame = (nodes: RawAndroidNode[]): AxRect => {
|
|
233
|
+
// Take the largest rect — uiautomator's first node is typically the
|
|
234
|
+
// screen-spanning FrameLayout, but tolerate weirdness by scanning.
|
|
235
|
+
let best: AxRect = { x: 0, y: 0, width: 1, height: 1 };
|
|
236
|
+
let bestArea = 0;
|
|
237
|
+
for (const n of nodes) {
|
|
238
|
+
const pb = n.parsedBounds;
|
|
239
|
+
if (!pb) continue;
|
|
240
|
+
const w = pb.right - pb.left;
|
|
241
|
+
const h = pb.bottom - pb.top;
|
|
242
|
+
const area = Math.max(0, w) * Math.max(0, h);
|
|
243
|
+
if (area > bestArea) {
|
|
244
|
+
bestArea = area;
|
|
245
|
+
best = { x: 0, y: 0, width: w, height: h };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return best;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const buildAndroidSelectors = (node: RawAndroidNode): AxSelectors => {
|
|
252
|
+
const sel: AxSelectors = {};
|
|
253
|
+
if (typeof node.resourceId === 'string' && node.resourceId.length > 0) sel.resourceId = node.resourceId;
|
|
254
|
+
if (typeof node.contentDesc === 'string' && node.contentDesc.length > 0) sel.contentDesc = node.contentDesc;
|
|
255
|
+
if (typeof node.text === 'string' && node.text.length > 0) sel.text = node.text;
|
|
256
|
+
if (typeof node.className === 'string' && node.className.length > 0) sel.className = node.className;
|
|
257
|
+
return sel;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export function normalizeAndroidTree(nodes: RawAndroidNode[]): AxSnapshot {
|
|
261
|
+
const screen = androidScreenFrame(nodes);
|
|
262
|
+
const elements: AxElement[] = [];
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < nodes.length && elements.length < MAX_ELEMENTS; i++) {
|
|
265
|
+
const node = nodes[i]!;
|
|
266
|
+
const pb = node.parsedBounds;
|
|
267
|
+
if (!pb) continue;
|
|
268
|
+
const width = Math.max(0, pb.right - pb.left);
|
|
269
|
+
const height = Math.max(0, pb.bottom - pb.top);
|
|
270
|
+
if (width <= 0 || height <= 0) continue;
|
|
271
|
+
const frame: AxRect = { x: pb.left, y: pb.top, width, height };
|
|
272
|
+
if (rectsApproxEqual(frame, { x: 0, y: 0, width: screen.width, height: screen.height })) continue;
|
|
273
|
+
|
|
274
|
+
const label = node.contentDesc || node.text || '';
|
|
275
|
+
const role = node.className || '';
|
|
276
|
+
elements.push({
|
|
277
|
+
id:
|
|
278
|
+
node.resourceId && node.resourceId.length > 0 ? node.resourceId
|
|
279
|
+
: node.contentDesc && node.contentDesc.length > 0 ? `cd:${node.contentDesc}`
|
|
280
|
+
: String(i),
|
|
281
|
+
path: String(i),
|
|
282
|
+
label,
|
|
283
|
+
value: typeof node.text === 'string' ? node.text : '',
|
|
284
|
+
role,
|
|
285
|
+
type: role,
|
|
286
|
+
enabled: node.enabled !== false,
|
|
287
|
+
focused: node.focused === true,
|
|
288
|
+
frame,
|
|
289
|
+
selectors: buildAndroidSelectors(node),
|
|
290
|
+
raw: { ...node },
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
platform: 'android',
|
|
296
|
+
screen: { width: screen.width, height: screen.height },
|
|
297
|
+
elements,
|
|
298
|
+
capturedAt: Date.now(),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
303
|
+
// Generic helpers (exported for customers building their own panels)
|
|
304
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
export function clampAxFrameForScreen(
|
|
307
|
+
frame: AxRect,
|
|
308
|
+
screen: { width: number; height: number },
|
|
309
|
+
): AxRect | null {
|
|
310
|
+
const x = Math.max(0, frame.x);
|
|
311
|
+
const y = Math.max(0, frame.y);
|
|
312
|
+
const right = Math.min(screen.width, frame.x + frame.width);
|
|
313
|
+
const bottom = Math.min(screen.height, frame.y + frame.height);
|
|
314
|
+
const width = Math.max(0, right - x);
|
|
315
|
+
const height = Math.max(0, bottom - y);
|
|
316
|
+
return width > 0 && height > 0 ? { x, y, width, height } : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function axElementsEqual(a: AxElement, b: AxElement): boolean {
|
|
320
|
+
if (a === b) return true;
|
|
321
|
+
if (a.id !== b.id || a.path !== b.path) return false;
|
|
322
|
+
if (a.label !== b.label || a.value !== b.value) return false;
|
|
323
|
+
if (a.role !== b.role || a.type !== b.type) return false;
|
|
324
|
+
if (a.enabled !== b.enabled || a.focused !== b.focused) return false;
|
|
325
|
+
const fa = a.frame;
|
|
326
|
+
const fb = b.frame;
|
|
327
|
+
return fa.x === fb.x && fa.y === fb.y && fa.width === fb.width && fa.height === fb.height;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function axSnapshotsEqual(a: AxSnapshot | null, b: AxSnapshot | null): boolean {
|
|
331
|
+
if (a === b) return true;
|
|
332
|
+
if (!a || !b) return false;
|
|
333
|
+
if (a.platform !== b.platform) return false;
|
|
334
|
+
if (a.screen.width !== b.screen.width || a.screen.height !== b.screen.height) return false;
|
|
335
|
+
if (a.elements.length !== b.elements.length) return false;
|
|
336
|
+
for (let i = 0; i < a.elements.length; i++) {
|
|
337
|
+
if (!axElementsEqual(a.elements[i]!, b.elements[i]!)) return false;
|
|
338
|
+
}
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Returns the smallest matching element under the given point (in screen
|
|
343
|
+
// coordinate space). Used by the overlay for hit-testing when boxes overlap.
|
|
344
|
+
export function axElementAtPoint(snapshot: AxSnapshot, x: number, y: number): AxElement | null {
|
|
345
|
+
let best: AxElement | null = null;
|
|
346
|
+
let bestArea = Infinity;
|
|
347
|
+
for (const el of snapshot.elements) {
|
|
348
|
+
const f = el.frame;
|
|
349
|
+
if (x < f.x || y < f.y || x > f.x + f.width || y > f.y + f.height) continue;
|
|
350
|
+
const area = rectArea(f);
|
|
351
|
+
if (area < bestArea) {
|
|
352
|
+
bestArea = area;
|
|
353
|
+
best = el;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return best;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Produces a one-line SDK selector expression that customers can paste into
|
|
360
|
+
// code. Returns null when no usable selector exists.
|
|
361
|
+
export function axElementSelectorExpression(el: AxElement, platform: AxPlatform): string | null {
|
|
362
|
+
if (platform === 'ios') {
|
|
363
|
+
if (el.selectors.AXUniqueId) {
|
|
364
|
+
return `client.tapElement({ AXUniqueId: ${JSON.stringify(el.selectors.AXUniqueId)} })`;
|
|
365
|
+
}
|
|
366
|
+
if (el.selectors.AXLabel) {
|
|
367
|
+
const typeHint = el.selectors.className ? `, type: ${JSON.stringify(el.selectors.className)}` : '';
|
|
368
|
+
return `client.tapElement({ AXLabel: ${JSON.stringify(el.selectors.AXLabel)}${typeHint} })`;
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
// android
|
|
373
|
+
if (el.selectors.resourceId) {
|
|
374
|
+
return `client.tap({ selector: { resourceId: ${JSON.stringify(el.selectors.resourceId)} } })`;
|
|
375
|
+
}
|
|
376
|
+
if (el.selectors.contentDesc) {
|
|
377
|
+
return `client.tap({ selector: { contentDesc: ${JSON.stringify(el.selectors.contentDesc)} } })`;
|
|
378
|
+
}
|
|
379
|
+
if (el.selectors.text) {
|
|
380
|
+
return `client.tap({ selector: { text: ${JSON.stringify(el.selectors.text)} } })`;
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Cleans up internal-looking role tokens. iOS's `role_description` can be
|
|
386
|
+
// raw strings like "AXGenericElement" or empty when AppKit doesn't have a
|
|
387
|
+
// description for the role. Android sets role to the className which is
|
|
388
|
+
// often fully-qualified (`android.widget.TextView`); strip the package.
|
|
389
|
+
export function axElementRoleLabel(el: AxElement): string {
|
|
390
|
+
const raw = el.role || el.type || '';
|
|
391
|
+
if (!raw) return 'element';
|
|
392
|
+
// Drop "AX" prefix and split CamelCase into spaced words ("AXTextField" → "Text Field").
|
|
393
|
+
let cleaned = raw.replace(/^AX/, '');
|
|
394
|
+
// For Android fully-qualified names, keep just the last segment.
|
|
395
|
+
if (cleaned.includes('.')) {
|
|
396
|
+
cleaned = cleaned.split('.').pop()!;
|
|
397
|
+
}
|
|
398
|
+
// Reject the generic catch-all bucket; callers can decide to hide it.
|
|
399
|
+
if (cleaned === 'GenericElement') return 'Element';
|
|
400
|
+
// Lightly humanize CamelCase.
|
|
401
|
+
cleaned = cleaned.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
402
|
+
return cleaned;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// A short human-readable summary used in tooltips and the info card title.
|
|
406
|
+
// Truncated to avoid blowing up tooltips on elements with paragraph-length
|
|
407
|
+
// AXLabels.
|
|
408
|
+
const SUMMARY_MAX_LABEL_LEN = 80;
|
|
409
|
+
export function axElementSummary(el: AxElement): string {
|
|
410
|
+
const role = axElementRoleLabel(el);
|
|
411
|
+
const text = el.label || el.value || '';
|
|
412
|
+
if (!text) return role;
|
|
413
|
+
const trimmed =
|
|
414
|
+
text.length > SUMMARY_MAX_LABEL_LEN ? text.slice(0, SUMMARY_MAX_LABEL_LEN).trimEnd() + '…' : text;
|
|
415
|
+
return `${role} · ${trimmed}`;
|
|
416
|
+
}
|
package/src/demo.tsx
CHANGED
|
@@ -1,17 +1,39 @@
|
|
|
1
1
|
import { useState, useRef } from 'react';
|
|
2
2
|
import { createRoot } from 'react-dom/client';
|
|
3
3
|
import { RemoteControl, RemoteControlHandle } from './components/remote-control';
|
|
4
|
+
import { AxSnapshot } from './core/ax-tree';
|
|
5
|
+
|
|
6
|
+
type InspectChoice = 'off' | 'hover-only' | 'select';
|
|
7
|
+
|
|
8
|
+
// Pre-fill from query string so the developer testing the demo can deeplink
|
|
9
|
+
// `?url=...&token=...&inspect=select&autoconnect=1`. Nothing is persisted.
|
|
10
|
+
const initialParams = new URLSearchParams(window.location.search);
|
|
11
|
+
const initialUrl = initialParams.get('url') || 'ws://localhost:8833/signaling';
|
|
12
|
+
const initialToken = initialParams.get('token') || 'token';
|
|
13
|
+
const initialPlatformParam = initialParams.get('platform');
|
|
14
|
+
const initialPlatform: 'ios' | 'android' = initialPlatformParam === 'android' ? 'android' : 'ios';
|
|
15
|
+
const initialInspect = (initialParams.get('inspect') as InspectChoice | null) ?? 'off';
|
|
16
|
+
const initialAutoconnect =
|
|
17
|
+
initialParams.get('autoconnect') === '1' && initialParams.has('url') && initialParams.has('token');
|
|
4
18
|
|
|
5
19
|
function Demo() {
|
|
6
|
-
const [url, setUrl] = useState(
|
|
7
|
-
const [token, setToken] = useState(
|
|
8
|
-
const [platform, setPlatform] = useState<'ios' | 'android'>(
|
|
9
|
-
const [isConnected, setIsConnected] = useState(
|
|
20
|
+
const [url, setUrl] = useState(initialUrl);
|
|
21
|
+
const [token, setToken] = useState(initialToken);
|
|
22
|
+
const [platform, setPlatform] = useState<'ios' | 'android'>(initialPlatform);
|
|
23
|
+
const [isConnected, setIsConnected] = useState(initialAutoconnect);
|
|
10
24
|
const [key, setKey] = useState(0);
|
|
11
25
|
const [showDebugInfo, setShowDebugInfo] = useState(false);
|
|
26
|
+
const [inspectChoice, setInspectChoice] = useState<InspectChoice>(initialInspect);
|
|
27
|
+
const [latestSnapshot, setLatestSnapshot] = useState<AxSnapshot | null>(null);
|
|
28
|
+
const [latestSelection, setLatestSelection] = useState<string | null>(null);
|
|
12
29
|
|
|
13
30
|
const remoteControlRef = useRef<RemoteControlHandle>(null);
|
|
14
31
|
|
|
32
|
+
const inspectModeProp: boolean | 'hover-only' | undefined =
|
|
33
|
+
inspectChoice === 'off' ? undefined
|
|
34
|
+
: inspectChoice === 'hover-only' ? 'hover-only'
|
|
35
|
+
: true;
|
|
36
|
+
|
|
15
37
|
const handleConnect = () => {
|
|
16
38
|
if (url) {
|
|
17
39
|
setIsConnected(true);
|
|
@@ -112,6 +134,19 @@ function Demo() {
|
|
|
112
134
|
</label>
|
|
113
135
|
</div>
|
|
114
136
|
|
|
137
|
+
<div className="control-group">
|
|
138
|
+
<label htmlFor="inspect-mode">Inspect Mode</label>
|
|
139
|
+
<select
|
|
140
|
+
id="inspect-mode"
|
|
141
|
+
value={inspectChoice}
|
|
142
|
+
onChange={(e) => setInspectChoice(e.target.value as InspectChoice)}
|
|
143
|
+
>
|
|
144
|
+
<option value="off">Off</option>
|
|
145
|
+
<option value="hover-only">Hover-only (input still works)</option>
|
|
146
|
+
<option value="select">Select (click to pin, ESC to clear)</option>
|
|
147
|
+
</select>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
115
150
|
<div className="button-group">
|
|
116
151
|
{!isConnected ?
|
|
117
152
|
<button className="primary" onClick={handleConnect} disabled={!url}>
|
|
@@ -153,14 +188,62 @@ function Demo() {
|
|
|
153
188
|
)}
|
|
154
189
|
|
|
155
190
|
{isConnected ?
|
|
156
|
-
|
|
157
|
-
<div className="preview
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
<
|
|
191
|
+
<>
|
|
192
|
+
<div className="device-preview">
|
|
193
|
+
<div className="preview-item">
|
|
194
|
+
<h3>{platform === 'ios' ? '📱 iOS with Frame' : '🤖 Android (No Frame)'}</h3>
|
|
195
|
+
<div className="device-wrapper">
|
|
196
|
+
<RemoteControl
|
|
197
|
+
key={key}
|
|
198
|
+
ref={remoteControlRef}
|
|
199
|
+
url={url}
|
|
200
|
+
token={token}
|
|
201
|
+
inspectMode={inspectModeProp}
|
|
202
|
+
onAxSnapshotChange={setLatestSnapshot}
|
|
203
|
+
onInspectSelectionChange={(sel) =>
|
|
204
|
+
setLatestSelection(
|
|
205
|
+
sel ?
|
|
206
|
+
`${sel.element.role || sel.element.type} · ${sel.element.label || '(no label)'}`
|
|
207
|
+
: null,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
161
212
|
</div>
|
|
162
213
|
</div>
|
|
163
|
-
|
|
214
|
+
|
|
215
|
+
{inspectChoice !== 'off' && (
|
|
216
|
+
<div
|
|
217
|
+
style={{
|
|
218
|
+
marginTop: '20px',
|
|
219
|
+
background: '#0f172a',
|
|
220
|
+
color: '#e2e8f0',
|
|
221
|
+
border: '1px solid #1e293b',
|
|
222
|
+
borderRadius: '8px',
|
|
223
|
+
padding: '12px 14px',
|
|
224
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
225
|
+
fontSize: '12px',
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
<div
|
|
229
|
+
style={{
|
|
230
|
+
fontWeight: 600,
|
|
231
|
+
marginBottom: '6px',
|
|
232
|
+
display: 'flex',
|
|
233
|
+
gap: '12px',
|
|
234
|
+
flexWrap: 'wrap',
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
<span>Inspect debug (demo-only)</span>
|
|
238
|
+
<span style={{ color: '#94a3b8', fontWeight: 400 }}>
|
|
239
|
+
elements: {latestSnapshot?.elements.length ?? 0} · screen:{' '}
|
|
240
|
+
{latestSnapshot ? `${latestSnapshot.screen.width}×${latestSnapshot.screen.height}` : '—'}{' '}
|
|
241
|
+
· platform: {latestSnapshot?.platform ?? '—'} · selection: {latestSelection ?? '(none)'}
|
|
242
|
+
</span>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
</>
|
|
164
247
|
: <div
|
|
165
248
|
style={{
|
|
166
249
|
textAlign: 'center',
|
package/src/index.ts
CHANGED
|
@@ -2,3 +2,20 @@ export { RemoteControl } from './components/remote-control';
|
|
|
2
2
|
export type { RemoteControlHandle } from './components/remote-control';
|
|
3
3
|
export { DeviceInstallDialog, DeviceInstallRelay } from './components/device-install';
|
|
4
4
|
export { useDeviceInstall } from './hooks/use-device-install';
|
|
5
|
+
|
|
6
|
+
// Accessibility / inspect-mode types and helpers. Exported so customers can
|
|
7
|
+
// build their own side panels, search UIs, or agent-driven inspectors on top
|
|
8
|
+
// of the snapshots delivered via `onAxSnapshotChange`.
|
|
9
|
+
export type { AxSnapshot, AxElement, AxRect, AxSelectors, AxPlatform } from './core/ax-tree';
|
|
10
|
+
export type { AxStatus } from './core/ax-fetcher';
|
|
11
|
+
export {
|
|
12
|
+
axElementAtPoint,
|
|
13
|
+
axElementSelectorExpression,
|
|
14
|
+
axElementSummary,
|
|
15
|
+
axElementsEqual,
|
|
16
|
+
axSnapshotsEqual,
|
|
17
|
+
clampAxFrameForScreen,
|
|
18
|
+
normalizeAndroidTree,
|
|
19
|
+
normalizeIosTree,
|
|
20
|
+
AX_UNAVAILABLE_ERROR,
|
|
21
|
+
} from './core/ax-tree';
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
// Vitest config for @limrun/ui unit tests.
|
|
4
|
+
//
|
|
5
|
+
// We use jsdom as the default environment because the runtime code uses
|
|
6
|
+
// browser globals (window.setTimeout, window.requestAnimationFrame, etc.).
|
|
7
|
+
// Pure modules can opt back into the node env per-file via:
|
|
8
|
+
//
|
|
9
|
+
// // @vitest-environment node
|
|
10
|
+
//
|
|
11
|
+
// at the top of the test file.
|
|
12
|
+
//
|
|
13
|
+
// The actual <RemoteControl> component is intentionally NOT under unit
|
|
14
|
+
// test here — its WebRTC plumbing is integration-tested via the demo +
|
|
15
|
+
// staging instance. These tests cover the smaller, pure-logic modules:
|
|
16
|
+
// `core/ax-tree.ts` and `core/ax-fetcher.ts`.
|
|
17
|
+
export default defineConfig({
|
|
18
|
+
test: {
|
|
19
|
+
environment: 'jsdom',
|
|
20
|
+
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
|
21
|
+
globals: false,
|
|
22
|
+
},
|
|
23
|
+
});
|