@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/ui",
3
- "version": "0.9.0-rc.6",
3
+ "version": "0.9.0-rc.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
- axElementSelectorExpression,
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
- const selectorExpr = useMemo(() => axElementSelectorExpression(element, platform), [element, platform]);
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 | undefined, key: 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={!selectorExpr}
312
- title={selectorExpr ?? 'No usable selector for this element'}
313
- onClick={() => handleCopy(selectorExpr ?? undefined, 'selector')}
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
- <button
318
- type="button"
319
- className={clsx('rc-inspect-card-btn', copied === 'id' && 'rc-inspect-card-btn-copied')}
320
- disabled={!primaryIdField}
321
- title={primaryIdField ?? `No ${primaryIdLabel}`}
322
- onClick={() => handleCopy(primaryIdField, 'id')}
323
- >
324
- {copied === 'id' ? 'Copied!' : `Copy ${primaryIdLabel}`}
325
- </button>
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 id), ESC clears.
69
- * Device input is blocked while in this mode.
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) => {
@@ -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');
@@ -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,