@limrun/ui 0.9.0-rc.6 → 0.9.0-rc.7
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 +1 -0
- package/dist/components/remote-control.d.ts +13 -2
- package/dist/core/ax-tree.d.ts +2 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +871 -830
- package/package.json +1 -1
- package/src/components/inspect-overlay.css +6 -0
- package/src/components/inspect-overlay.tsx +46 -15
- package/src/components/remote-control.tsx +16 -2
- package/src/core/ax-tree.test.ts +124 -0
- package/src/core/ax-tree.ts +107 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
/* When click-to-select is enabled, the container also captures clicks
|
|
10
10
|
that fall outside any box so we can clear selection. */
|
|
11
11
|
pointer-events: auto;
|
|
12
|
+
/* Match Chrome DevTools' crosshair cursor while inspect mode is active.
|
|
13
|
+
The merged `.rc-inspect-overlay-select .rc-inspect-box` rule below
|
|
14
|
+
restates the cursor on each inner box, since `.rc-inspect-box` sets
|
|
15
|
+
its own `cursor: pointer`. */
|
|
16
|
+
cursor: crosshair;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
.rc-inspect-box {
|
|
@@ -32,6 +37,7 @@
|
|
|
32
37
|
|
|
33
38
|
.rc-inspect-overlay-select .rc-inspect-box {
|
|
34
39
|
pointer-events: auto;
|
|
40
|
+
cursor: crosshair;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
.rc-inspect-box-disabled {
|
|
@@ -7,8 +7,9 @@ import {
|
|
|
7
7
|
AxElement,
|
|
8
8
|
AxPlatform,
|
|
9
9
|
AxSnapshot,
|
|
10
|
+
axCliTapCommand,
|
|
10
11
|
axElementRoleLabel,
|
|
11
|
-
|
|
12
|
+
axElementSelectorObject,
|
|
12
13
|
axElementSummary,
|
|
13
14
|
axElementsEqual,
|
|
14
15
|
clampAxFrameForScreen,
|
|
@@ -37,6 +38,10 @@ export interface InspectOverlayProps {
|
|
|
37
38
|
highlightedId: string | null;
|
|
38
39
|
selectedId: string | null;
|
|
39
40
|
mode: InspectMode;
|
|
41
|
+
// Optional instance id used to render the "Copy command" CLI snippet
|
|
42
|
+
// (e.g. `lim ios tap-element --ax-label 'Sign in' --id <instanceId>`)
|
|
43
|
+
// in the info card. When omitted the Copy command button is hidden.
|
|
44
|
+
instanceId?: string;
|
|
40
45
|
// Current pointer position in viewport coordinates (clientX/Y). Drives the
|
|
41
46
|
// cursor-anchored preview card while hovering. null when the pointer is
|
|
42
47
|
// outside the device.
|
|
@@ -117,6 +122,10 @@ const InspectBox = memo(
|
|
|
117
122
|
if (!selectable) return;
|
|
118
123
|
e.preventDefault();
|
|
119
124
|
e.stopPropagation();
|
|
125
|
+
// Chrome DevTools parity: clicking the already-selected element
|
|
126
|
+
// is a no-op. Re-firing the selection change callback would
|
|
127
|
+
// needlessly reset the card anchor and consumer-side state.
|
|
128
|
+
if (selected) return;
|
|
120
129
|
onClick(element, { x: e.clientX, y: e.clientY });
|
|
121
130
|
}}
|
|
122
131
|
style={{
|
|
@@ -151,6 +160,9 @@ interface InfoCardProps {
|
|
|
151
160
|
anchor: { x: number; y: number };
|
|
152
161
|
cursorAnchored: boolean;
|
|
153
162
|
showActions: boolean;
|
|
163
|
+
// Used to render the "Copy command" CLI snippet. When absent the button
|
|
164
|
+
// is hidden.
|
|
165
|
+
instanceId?: string;
|
|
154
166
|
// Receives the element AND the viewport-space coordinate to tap at
|
|
155
167
|
// (the frozen click position). Tapping at the click point — rather than
|
|
156
168
|
// the element's frame center — preserves the user's aim when the
|
|
@@ -180,6 +192,7 @@ const InfoCard = memo(function InfoCard({
|
|
|
180
192
|
anchor,
|
|
181
193
|
cursorAnchored,
|
|
182
194
|
showActions,
|
|
195
|
+
instanceId,
|
|
183
196
|
onTap,
|
|
184
197
|
}: InfoCardProps) {
|
|
185
198
|
const [copied, setCopied] = useState<string | null>(null);
|
|
@@ -235,11 +248,26 @@ const InfoCard = memo(function InfoCard({
|
|
|
235
248
|
return { left: `${left}px`, top: `${top}px`, transform };
|
|
236
249
|
}, [anchor.x, anchor.y]);
|
|
237
250
|
|
|
238
|
-
|
|
251
|
+
// "Copy selector" returns the raw selector hash as JSON (the same shape
|
|
252
|
+
// accepted by the CLI / instance API). Every available selector field is
|
|
253
|
+
// included so the consumer can disambiguate (e.g. `AXLabel + type`).
|
|
254
|
+
const selectorObject = useMemo(() => axElementSelectorObject(element, platform), [element, platform]);
|
|
255
|
+
const selectorJson = useMemo(
|
|
256
|
+
() => (selectorObject ? JSON.stringify(selectorObject, null, 2) : null),
|
|
257
|
+
[selectorObject],
|
|
258
|
+
);
|
|
259
|
+
// "Copy command" emits a selector-based `lim … tap-element …` invocation
|
|
260
|
+
// that targets this exact element on this exact instance, robust to
|
|
261
|
+
// layout changes that would invalidate a coordinate-based tap.
|
|
262
|
+
const cliCommand = useMemo(
|
|
263
|
+
() => (instanceId ? axCliTapCommand(element, platform, instanceId) : null),
|
|
264
|
+
[element, platform, instanceId],
|
|
265
|
+
);
|
|
266
|
+
|
|
239
267
|
const primaryIdField = platform === 'ios' ? element.selectors.AXUniqueId : element.selectors.resourceId;
|
|
240
268
|
const primaryIdLabel = platform === 'ios' ? 'AXUniqueId' : 'resourceId';
|
|
241
269
|
|
|
242
|
-
const handleCopy = useCallback(async (text: string |
|
|
270
|
+
const handleCopy = useCallback(async (text: string | null, key: string) => {
|
|
243
271
|
if (!text) return;
|
|
244
272
|
const ok = await copyToClipboard(text);
|
|
245
273
|
if (ok) setCopied(key);
|
|
@@ -308,21 +336,22 @@ const InfoCard = memo(function InfoCard({
|
|
|
308
336
|
<button
|
|
309
337
|
type="button"
|
|
310
338
|
className={clsx('rc-inspect-card-btn', copied === 'selector' && 'rc-inspect-card-btn-copied')}
|
|
311
|
-
disabled={!
|
|
312
|
-
title={
|
|
313
|
-
onClick={() => handleCopy(
|
|
339
|
+
disabled={!selectorJson}
|
|
340
|
+
title={selectorJson ?? 'No usable selector for this element'}
|
|
341
|
+
onClick={() => handleCopy(selectorJson, 'selector')}
|
|
314
342
|
>
|
|
315
343
|
{copied === 'selector' ? 'Copied!' : 'Copy selector'}
|
|
316
344
|
</button>
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
345
|
+
{cliCommand && (
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
className={clsx('rc-inspect-card-btn', copied === 'command' && 'rc-inspect-card-btn-copied')}
|
|
349
|
+
title={cliCommand}
|
|
350
|
+
onClick={() => handleCopy(cliCommand, 'command')}
|
|
351
|
+
>
|
|
352
|
+
{copied === 'command' ? 'Copied!' : 'Copy command'}
|
|
353
|
+
</button>
|
|
354
|
+
)}
|
|
326
355
|
</div>
|
|
327
356
|
)}
|
|
328
357
|
</div>,
|
|
@@ -340,6 +369,7 @@ export const InspectOverlay = memo(function InspectOverlay({
|
|
|
340
369
|
highlightedId,
|
|
341
370
|
selectedId,
|
|
342
371
|
mode,
|
|
372
|
+
instanceId,
|
|
343
373
|
cursorPosition,
|
|
344
374
|
frozenCursorPosition,
|
|
345
375
|
onSelectChange,
|
|
@@ -429,6 +459,7 @@ export const InspectOverlay = memo(function InspectOverlay({
|
|
|
429
459
|
anchor={anchor}
|
|
430
460
|
cursorAnchored={cursorAnchored}
|
|
431
461
|
showActions={showActions}
|
|
462
|
+
instanceId={instanceId}
|
|
432
463
|
onTap={onTapElement}
|
|
433
464
|
/>
|
|
434
465
|
)}
|
|
@@ -65,8 +65,10 @@ interface RemoteControlProps {
|
|
|
65
65
|
* video stream.
|
|
66
66
|
*
|
|
67
67
|
* - `true` — Select mode. Boxes are clickable, click pins a selection
|
|
68
|
-
* with action buttons (Tap / Copy selector / Copy
|
|
69
|
-
*
|
|
68
|
+
* with action buttons (Tap / Copy selector / Copy command), ESC
|
|
69
|
+
* clears. The cursor turns into a crosshair while inspecting and the
|
|
70
|
+
* info card hangs off the pointer. Device input is blocked while in
|
|
71
|
+
* this mode.
|
|
70
72
|
* - `'hover-only'` — Boxes follow the cursor as a visual preview. Device
|
|
71
73
|
* input still passes through, so you can drive the simulator while
|
|
72
74
|
* inspecting.
|
|
@@ -74,6 +76,16 @@ interface RemoteControlProps {
|
|
|
74
76
|
*/
|
|
75
77
|
inspectMode?: boolean | 'hover-only';
|
|
76
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Optional instance id used to render the "Copy command" button in the
|
|
81
|
+
* inspect-mode info card. When provided, the button copies a CLI
|
|
82
|
+
* invocation like
|
|
83
|
+
* `lim ios tap-element --ax-label 'Sign in' --type Button --id <instanceId>`
|
|
84
|
+
* (or its Android equivalent) that targets this exact element on this
|
|
85
|
+
* exact instance. When omitted the button is hidden.
|
|
86
|
+
*/
|
|
87
|
+
instanceId?: string;
|
|
88
|
+
|
|
77
89
|
/**
|
|
78
90
|
* Fires whenever a fresh accessibility snapshot is delivered.
|
|
79
91
|
*
|
|
@@ -333,6 +345,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
333
345
|
showFrame = true,
|
|
334
346
|
autoReconnect = false,
|
|
335
347
|
inspectMode,
|
|
348
|
+
instanceId,
|
|
336
349
|
onAxSnapshotChange,
|
|
337
350
|
onInspectSelectionChange,
|
|
338
351
|
onAxStatusChange,
|
|
@@ -2529,6 +2542,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2529
2542
|
highlightedId={axHighlightedId}
|
|
2530
2543
|
selectedId={axSelectedId}
|
|
2531
2544
|
mode={inspectModeResolved}
|
|
2545
|
+
instanceId={instanceId}
|
|
2532
2546
|
cursorPosition={axCursorPosition}
|
|
2533
2547
|
frozenCursorPosition={axFrozenCursorPosition}
|
|
2534
2548
|
onSelectChange={(element, clickPosition) => {
|
package/src/core/ax-tree.test.ts
CHANGED
|
@@ -11,9 +11,11 @@ import {
|
|
|
11
11
|
AxElement,
|
|
12
12
|
AxSnapshot,
|
|
13
13
|
AX_UNAVAILABLE_ERROR,
|
|
14
|
+
axCliTapCommand,
|
|
14
15
|
axElementAtPoint,
|
|
15
16
|
axElementRoleLabel,
|
|
16
17
|
axElementSelectorExpression,
|
|
18
|
+
axElementSelectorObject,
|
|
17
19
|
axElementSummary,
|
|
18
20
|
axElementsEqual,
|
|
19
21
|
axSnapshotsEqual,
|
|
@@ -483,6 +485,128 @@ describe('axElementSelectorExpression', () => {
|
|
|
483
485
|
});
|
|
484
486
|
});
|
|
485
487
|
|
|
488
|
+
describe('axElementSelectorObject', () => {
|
|
489
|
+
const mk = (sel: Partial<AxElement['selectors']>, value = ''): AxElement => ({
|
|
490
|
+
id: '0',
|
|
491
|
+
path: '0',
|
|
492
|
+
label: '',
|
|
493
|
+
value,
|
|
494
|
+
role: '',
|
|
495
|
+
type: '',
|
|
496
|
+
enabled: true,
|
|
497
|
+
focused: false,
|
|
498
|
+
frame: { x: 0, y: 0, width: 1, height: 1 },
|
|
499
|
+
selectors: sel,
|
|
500
|
+
raw: {},
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('iOS: includes every available selector field, mapping className to type', () => {
|
|
504
|
+
expect(
|
|
505
|
+
axElementSelectorObject(mk({ AXUniqueId: 'signin', AXLabel: 'Sign in', className: 'Button' }), 'ios'),
|
|
506
|
+
).toEqual({ AXUniqueId: 'signin', AXLabel: 'Sign in', type: 'Button' });
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('iOS: falls back to AXLabel + type when AXUniqueId is missing', () => {
|
|
510
|
+
expect(axElementSelectorObject(mk({ AXLabel: 'OK', className: 'Button' }), 'ios')).toEqual({
|
|
511
|
+
AXLabel: 'OK',
|
|
512
|
+
type: 'Button',
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('iOS: returns null when only a generic type/className is available', () => {
|
|
517
|
+
expect(axElementSelectorObject(mk({ className: 'Button' }), 'ios')).toBeNull();
|
|
518
|
+
expect(axElementSelectorObject(mk({}), 'ios')).toBeNull();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('Android: includes every available selector field', () => {
|
|
522
|
+
expect(
|
|
523
|
+
axElementSelectorObject(
|
|
524
|
+
mk({
|
|
525
|
+
resourceId: 'com.example:id/submit',
|
|
526
|
+
contentDesc: 'Submit',
|
|
527
|
+
text: 'Submit',
|
|
528
|
+
className: 'android.widget.Button',
|
|
529
|
+
}),
|
|
530
|
+
'android',
|
|
531
|
+
),
|
|
532
|
+
).toEqual({
|
|
533
|
+
resourceId: 'com.example:id/submit',
|
|
534
|
+
contentDesc: 'Submit',
|
|
535
|
+
text: 'Submit',
|
|
536
|
+
className: 'android.widget.Button',
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('Android: returns null when only a className is present', () => {
|
|
541
|
+
expect(axElementSelectorObject(mk({ className: 'android.widget.View' }), 'android')).toBeNull();
|
|
542
|
+
expect(axElementSelectorObject(mk({}), 'android')).toBeNull();
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
describe('axCliTapCommand', () => {
|
|
547
|
+
const mk = (sel: Partial<AxElement['selectors']>): AxElement => ({
|
|
548
|
+
id: '0',
|
|
549
|
+
path: '0',
|
|
550
|
+
label: '',
|
|
551
|
+
value: '',
|
|
552
|
+
role: '',
|
|
553
|
+
type: '',
|
|
554
|
+
enabled: true,
|
|
555
|
+
focused: false,
|
|
556
|
+
frame: { x: 0, y: 0, width: 1, height: 1 },
|
|
557
|
+
selectors: sel,
|
|
558
|
+
raw: {},
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('iOS: emits tap-element with every available selector flag', () => {
|
|
562
|
+
expect(
|
|
563
|
+
axCliTapCommand(
|
|
564
|
+
mk({ AXUniqueId: 'signin', AXLabel: 'Sign in', className: 'Button' }),
|
|
565
|
+
'ios',
|
|
566
|
+
'ios_abc',
|
|
567
|
+
),
|
|
568
|
+
).toBe(`lim ios tap-element --ax-unique-id signin --ax-label 'Sign in' --type Button --id ios_abc`);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('iOS: AXLabel + type fallback when AXUniqueId is missing', () => {
|
|
572
|
+
expect(axCliTapCommand(mk({ AXLabel: 'OK', className: 'Button' }), 'ios', 'ios_abc')).toBe(
|
|
573
|
+
`lim ios tap-element --ax-label OK --type Button --id ios_abc`,
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('Android: emits tap-element with resource-id / content-desc / text / class-name', () => {
|
|
578
|
+
expect(
|
|
579
|
+
axCliTapCommand(
|
|
580
|
+
mk({
|
|
581
|
+
resourceId: 'com.example:id/submit',
|
|
582
|
+
contentDesc: 'Submit button',
|
|
583
|
+
text: 'Submit',
|
|
584
|
+
className: 'android.widget.Button',
|
|
585
|
+
}),
|
|
586
|
+
'android',
|
|
587
|
+
'and_1',
|
|
588
|
+
),
|
|
589
|
+
).toBe(
|
|
590
|
+
`lim android tap-element --resource-id com.example:id/submit --content-desc 'Submit button' --text Submit --class-name android.widget.Button --id and_1`,
|
|
591
|
+
);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test('shell-quotes values containing apostrophes', () => {
|
|
595
|
+
expect(axCliTapCommand(mk({ AXLabel: `Bob's button` }), 'ios', 'ios_abc')).toBe(
|
|
596
|
+
`lim ios tap-element --ax-label 'Bob'\\''s button' --id ios_abc`,
|
|
597
|
+
);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test('returns null when no specific selector is available', () => {
|
|
601
|
+
expect(axCliTapCommand(mk({ className: 'Button' }), 'ios', 'ios_abc')).toBeNull();
|
|
602
|
+
expect(axCliTapCommand(mk({}), 'android', 'and_1')).toBeNull();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test('returns null when instanceId is empty', () => {
|
|
606
|
+
expect(axCliTapCommand(mk({ AXUniqueId: 'signin' }), 'ios', '')).toBeNull();
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
486
610
|
describe('AX_UNAVAILABLE_ERROR', () => {
|
|
487
611
|
test('is a non-empty string customers can compare against', () => {
|
|
488
612
|
expect(typeof AX_UNAVAILABLE_ERROR).toBe('string');
|
package/src/core/ax-tree.ts
CHANGED
|
@@ -382,6 +382,113 @@ export function axElementSelectorExpression(el: AxElement, platform: AxPlatform)
|
|
|
382
382
|
return null;
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
// Returns a plain selector object for the element using the same key names the
|
|
386
|
+
// CLI / instance API expect (`AXUniqueId`, `AXLabel`, `type` on iOS;
|
|
387
|
+
// `resourceId`, `contentDesc`, `text`, `className` on Android). Includes every
|
|
388
|
+
// CLI-supportable selector field present on the element so the consumer can
|
|
389
|
+
// disambiguate (e.g. `{AXLabel:"Submit", type:"Button"}`).
|
|
390
|
+
//
|
|
391
|
+
// Returns null when the element has no anchor field specific enough to drive
|
|
392
|
+
// a tap — a lone `type` / `className` is intentionally treated as
|
|
393
|
+
// non-actionable to avoid copying a near-useless selector. This mirrors the
|
|
394
|
+
// precedence used by `axElementSelectorExpression`.
|
|
395
|
+
export function axElementSelectorObject(el: AxElement, platform: AxPlatform): Record<string, string> | null {
|
|
396
|
+
const out: Record<string, string> = {};
|
|
397
|
+
let hasSpecific = false;
|
|
398
|
+
if (platform === 'ios') {
|
|
399
|
+
if (el.selectors.AXUniqueId) {
|
|
400
|
+
out.AXUniqueId = el.selectors.AXUniqueId;
|
|
401
|
+
hasSpecific = true;
|
|
402
|
+
}
|
|
403
|
+
if (el.selectors.AXLabel) {
|
|
404
|
+
out.AXLabel = el.selectors.AXLabel;
|
|
405
|
+
hasSpecific = true;
|
|
406
|
+
}
|
|
407
|
+
// `selectors.className` carries the iOS element `type` (e.g. "Button").
|
|
408
|
+
// The CLI / API expose it under the key `type`, so rename here.
|
|
409
|
+
if (el.selectors.className) {
|
|
410
|
+
out.type = el.selectors.className;
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
if (el.selectors.resourceId) {
|
|
414
|
+
out.resourceId = el.selectors.resourceId;
|
|
415
|
+
hasSpecific = true;
|
|
416
|
+
}
|
|
417
|
+
if (el.selectors.contentDesc) {
|
|
418
|
+
out.contentDesc = el.selectors.contentDesc;
|
|
419
|
+
hasSpecific = true;
|
|
420
|
+
}
|
|
421
|
+
if (el.selectors.text) {
|
|
422
|
+
out.text = el.selectors.text;
|
|
423
|
+
hasSpecific = true;
|
|
424
|
+
}
|
|
425
|
+
if (el.selectors.className) {
|
|
426
|
+
out.className = el.selectors.className;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return hasSpecific ? out : null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// POSIX-safe single quoting for shell command rendering. Bare-prints "safe"
|
|
433
|
+
// tokens (alphanumerics, slashes, dots, etc.) so common identifiers like
|
|
434
|
+
// UUIDs and Android resource IDs (`com.example:id/submit`) stay readable;
|
|
435
|
+
// everything else gets single-quoted with internal apostrophes escaped as
|
|
436
|
+
// `'\''`.
|
|
437
|
+
const shellQuote = (value: string): string => {
|
|
438
|
+
if (value === '') return "''";
|
|
439
|
+
if (/^[A-Za-z0-9_./\-:@%+=,]+$/.test(value)) return value;
|
|
440
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// Maps `axElementSelectorObject` keys to their `lim … tap-element` CLI
|
|
444
|
+
// flags. Scoped to the keys emitted by `axElementSelectorObject` — any
|
|
445
|
+
// future selector field added there must also be added here.
|
|
446
|
+
const CLI_FLAG_BY_SELECTOR_KEY: Record<AxPlatform, Record<string, string>> = {
|
|
447
|
+
ios: {
|
|
448
|
+
AXUniqueId: '--ax-unique-id',
|
|
449
|
+
AXLabel: '--ax-label',
|
|
450
|
+
type: '--type',
|
|
451
|
+
},
|
|
452
|
+
android: {
|
|
453
|
+
resourceId: '--resource-id',
|
|
454
|
+
contentDesc: '--content-desc',
|
|
455
|
+
text: '--text',
|
|
456
|
+
className: '--class-name',
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// Renders the `lim` CLI command that taps the element via selector flags
|
|
461
|
+
// rather than coordinates. Includes every CLI-supportable selector field
|
|
462
|
+
// present on the element (same precedence as `axElementSelectorObject`) so
|
|
463
|
+
// matches are unambiguous and resilient to layout changes.
|
|
464
|
+
//
|
|
465
|
+
// Example outputs:
|
|
466
|
+
// lim ios tap-element --ax-unique-id signin --id <id>
|
|
467
|
+
// lim ios tap-element --ax-label 'Sign in' --type Button --id <id>
|
|
468
|
+
// lim android tap-element --resource-id com.example:id/submit --id <id>
|
|
469
|
+
//
|
|
470
|
+
// Returns null when:
|
|
471
|
+
// - `instanceId` is empty, or
|
|
472
|
+
// - the element has no field specific enough to drive a selector tap
|
|
473
|
+
// (matches `axElementSelectorObject` returning null — e.g. only a
|
|
474
|
+
// generic className/type is available).
|
|
475
|
+
export function axCliTapCommand(el: AxElement, platform: AxPlatform, instanceId: string): string | null {
|
|
476
|
+
if (!instanceId) return null;
|
|
477
|
+
const selectors = axElementSelectorObject(el, platform);
|
|
478
|
+
if (!selectors) return null;
|
|
479
|
+
|
|
480
|
+
const flagMap = CLI_FLAG_BY_SELECTOR_KEY[platform];
|
|
481
|
+
const parts: string[] = ['lim', platform, 'tap-element'];
|
|
482
|
+
for (const [key, flag] of Object.entries(flagMap)) {
|
|
483
|
+
const value = selectors[key];
|
|
484
|
+
if (value) {
|
|
485
|
+
parts.push(flag, shellQuote(value));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
parts.push('--id', shellQuote(instanceId));
|
|
489
|
+
return parts.join(' ');
|
|
490
|
+
}
|
|
491
|
+
|
|
385
492
|
// Cleans up internal-looking role tokens. iOS's `role_description` can be
|
|
386
493
|
// raw strings like "AXGenericElement" or empty when AppKit doesn't have a
|
|
387
494
|
// description for the role. Android sets role to the className which is
|
package/src/index.ts
CHANGED
|
@@ -9,8 +9,10 @@ export { useDeviceInstall } from './hooks/use-device-install';
|
|
|
9
9
|
export type { AxSnapshot, AxElement, AxRect, AxSelectors, AxPlatform } from './core/ax-tree';
|
|
10
10
|
export type { AxStatus } from './core/ax-fetcher';
|
|
11
11
|
export {
|
|
12
|
+
axCliTapCommand,
|
|
12
13
|
axElementAtPoint,
|
|
13
14
|
axElementSelectorExpression,
|
|
15
|
+
axElementSelectorObject,
|
|
14
16
|
axElementSummary,
|
|
15
17
|
axElementsEqual,
|
|
16
18
|
axSnapshotsEqual,
|