@limrun/api 0.20.3 → 0.21.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/client.d.mts +1 -1
  3. package/client.d.mts.map +1 -1
  4. package/client.d.ts +1 -1
  5. package/client.d.ts.map +1 -1
  6. package/client.js +15 -17
  7. package/client.js.map +1 -1
  8. package/client.mjs +15 -17
  9. package/client.mjs.map +1 -1
  10. package/instance-client.d.mts +109 -0
  11. package/instance-client.d.mts.map +1 -1
  12. package/instance-client.d.ts +109 -0
  13. package/instance-client.d.ts.map +1 -1
  14. package/instance-client.js +262 -69
  15. package/instance-client.js.map +1 -1
  16. package/instance-client.mjs +262 -69
  17. package/instance-client.mjs.map +1 -1
  18. package/internal/utils/query.d.mts +5 -0
  19. package/internal/utils/query.d.mts.map +1 -0
  20. package/internal/utils/query.d.ts +5 -0
  21. package/internal/utils/query.d.ts.map +1 -0
  22. package/internal/utils/query.js +23 -0
  23. package/internal/utils/query.js.map +1 -0
  24. package/internal/utils/query.mjs +20 -0
  25. package/internal/utils/query.mjs.map +1 -0
  26. package/internal/utils.d.mts +1 -0
  27. package/internal/utils.d.ts +1 -0
  28. package/internal/utils.js +1 -0
  29. package/internal/utils.js.map +1 -1
  30. package/internal/utils.mjs +1 -0
  31. package/package.json +12 -1
  32. package/resources/android-instances.d.mts +1 -0
  33. package/resources/android-instances.d.mts.map +1 -1
  34. package/resources/android-instances.d.ts +1 -0
  35. package/resources/android-instances.d.ts.map +1 -1
  36. package/src/client.ts +18 -21
  37. package/src/instance-client.ts +516 -96
  38. package/src/internal/utils/query.ts +23 -0
  39. package/src/internal/utils.ts +1 -0
  40. package/src/resources/android-instances.ts +2 -0
  41. package/src/version.ts +1 -1
  42. package/version.d.mts +1 -1
  43. package/version.d.ts +1 -1
  44. package/version.js +1 -1
  45. package/version.mjs +1 -1
@@ -23,6 +23,47 @@ export type InstanceClient = {
23
23
  * @returns A promise that resolves to the screenshot data
24
24
  */
25
25
  screenshot: () => Promise<ScreenshotData>;
26
+ /**
27
+ * Fetch Android UI hierarchy from UIAutomator.
28
+ */
29
+ getElementTree: () => Promise<ElementTreeData>;
30
+ /**
31
+ * Find matching elements by Android-native selector.
32
+ */
33
+ findElement: (selector: AndroidSelector, limit?: number) => Promise<FindElementResult>;
34
+ /**
35
+ * Tap an element by selector/reference (or explicit coordinates).
36
+ */
37
+ tap: (target: AndroidElementTarget) => Promise<TapResult>;
38
+ /**
39
+ * Set text into an element or currently focused input.
40
+ */
41
+ setText: (target: AndroidElementTarget | undefined, text: string) => Promise<SetTextResult>;
42
+ /**
43
+ * Press an Android key by key name, optionally with modifiers.
44
+ * Accepted key strings are case-insensitive and may be plain names like
45
+ * `'BACK'`, `'ENTER'`, `'A'`, `'TAB'`, full Android constants like
46
+ * `'KEYCODE_TAB'`, or digit strings like `'4'`.
47
+ * Supported modifiers are `'shift'`, `'ctrl'`/`'control'`, `'alt'`/`'option'`,
48
+ * `'meta'`/`'command'`/`'cmd'`, `'sym'`, and `'fn'`/`'function'`.
49
+ */
50
+ pressKey: (key: string, modifiers?: string[]) => Promise<PressKeyResult>;
51
+ /**
52
+ * Scroll around the entire screen.
53
+ */
54
+ scrollScreen: (direction: ScrollDirection, amount?: number) => Promise<ScrollResult>;
55
+ /**
56
+ * Scroll inside an element matched by selector/reference.
57
+ */
58
+ scrollElement: (
59
+ target: AndroidElementTarget,
60
+ direction: ScrollDirection,
61
+ amount?: number,
62
+ ) => Promise<ScrollResult>;
63
+ /**
64
+ * Open a URL/deeplink on Android.
65
+ */
66
+ openUrl: (url: string) => Promise<OpenUrlResult>;
26
67
  /**
27
68
  * Disconnect from the Limbar instance
28
69
  */
@@ -57,6 +98,54 @@ export type InstanceClient = {
57
98
  */
58
99
  export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
59
100
 
101
+ export type ScrollDirection = 'up' | 'down' | 'left' | 'right';
102
+
103
+ export type AndroidSelector = {
104
+ resourceId?: string;
105
+ text?: string;
106
+ contentDesc?: string;
107
+ className?: string;
108
+ packageName?: string;
109
+ index?: number;
110
+ clickable?: boolean;
111
+ enabled?: boolean;
112
+ focused?: boolean;
113
+ boundsContains?: {
114
+ x: number;
115
+ y: number;
116
+ };
117
+ };
118
+
119
+ export type AndroidElementTarget = {
120
+ selector?: AndroidSelector;
121
+ x?: number;
122
+ y?: number;
123
+ };
124
+
125
+ export type AndroidElementNode = {
126
+ index?: string;
127
+ text?: string;
128
+ resourceId?: string;
129
+ className?: string;
130
+ packageName?: string;
131
+ contentDesc?: string;
132
+ clickable?: boolean;
133
+ enabled?: boolean;
134
+ focusable?: boolean;
135
+ focused?: boolean;
136
+ scrollable?: boolean;
137
+ selected?: boolean;
138
+ bounds?: string;
139
+ parsedBounds?: {
140
+ left: number;
141
+ top: number;
142
+ right: number;
143
+ bottom: number;
144
+ centerX: number;
145
+ centerY: number;
146
+ };
147
+ };
148
+
60
149
  /**
61
150
  * Configuration options for creating an Instance API client
62
151
  */
@@ -100,11 +189,6 @@ export type InstanceClientOptions = {
100
189
  maxReconnectDelay?: number;
101
190
  };
102
191
 
103
- type ScreenshotRequest = {
104
- type: 'screenshot';
105
- id: string;
106
- };
107
-
108
192
  type ScreenshotResponse = {
109
193
  type: 'screenshot';
110
194
  dataUri: string;
@@ -115,6 +199,41 @@ type ScreenshotData = {
115
199
  dataUri: string;
116
200
  };
117
201
 
202
+ export type ElementTreeData = {
203
+ xml: string;
204
+ nodes: AndroidElementNode[];
205
+ };
206
+
207
+ export type FindElementResult = {
208
+ elements: AndroidElementNode[];
209
+ count: number;
210
+ };
211
+
212
+ export type TapResult = {
213
+ x: number;
214
+ y: number;
215
+ };
216
+
217
+ export type SetTextResult = {
218
+ textLength: number;
219
+ };
220
+
221
+ export type PressKeyResult = {
222
+ key: string;
223
+ };
224
+
225
+ export type ScrollResult = {
226
+ direction: ScrollDirection;
227
+ startX: number;
228
+ startY: number;
229
+ endX: number;
230
+ endY: number;
231
+ };
232
+
233
+ export type OpenUrlResult = {
234
+ url: string;
235
+ };
236
+
118
237
  type ScreenshotErrorResponse = {
119
238
  type: 'screenshotError';
120
239
  message: string;
@@ -133,12 +252,123 @@ type AssetResultResponse = {
133
252
  message?: string;
134
253
  };
135
254
 
255
+ type CommandError = {
256
+ code?: string;
257
+ message?: string;
258
+ retriable?: boolean;
259
+ };
260
+
261
+ type ScreenshotResultMessage = {
262
+ type: 'screenshotResult';
263
+ id: string;
264
+ payload?: ScreenshotData;
265
+ error?: CommandError;
266
+ };
267
+
268
+ type GetElementTreeResultMessage = {
269
+ type: 'getElementTreeResult';
270
+ id: string;
271
+ payload?: ElementTreeData;
272
+ error?: CommandError;
273
+ };
274
+
275
+ type FindElementResultMessage = {
276
+ type: 'findElementResult';
277
+ id: string;
278
+ payload?: FindElementResult;
279
+ error?: CommandError;
280
+ };
281
+
282
+ type TapResultMessage = {
283
+ type: 'tapResult';
284
+ id: string;
285
+ payload?: TapResult;
286
+ error?: CommandError;
287
+ };
288
+
289
+ type SetTextResultMessage = {
290
+ type: 'setTextResult';
291
+ id: string;
292
+ payload?: SetTextResult;
293
+ error?: CommandError;
294
+ };
295
+
296
+ type PressKeyResultMessage = {
297
+ type: 'pressKeyResult';
298
+ id: string;
299
+ payload?: PressKeyResult;
300
+ error?: CommandError;
301
+ };
302
+
303
+ type ScrollScreenResultMessage = {
304
+ type: 'scrollScreenResult';
305
+ id: string;
306
+ payload?: ScrollResult;
307
+ error?: CommandError;
308
+ };
309
+
310
+ type ScrollElementResultMessage = {
311
+ type: 'scrollElementResult';
312
+ id: string;
313
+ payload?: ScrollResult;
314
+ error?: CommandError;
315
+ };
316
+
317
+ type OpenUrlResultMessage = {
318
+ type: 'openUrlResult';
319
+ id: string;
320
+ payload?: OpenUrlResult;
321
+ error?: CommandError;
322
+ };
323
+
324
+ type KnownCommandResultMessage =
325
+ | ScreenshotResultMessage
326
+ | GetElementTreeResultMessage
327
+ | FindElementResultMessage
328
+ | TapResultMessage
329
+ | SetTextResultMessage
330
+ | PressKeyResultMessage
331
+ | ScrollScreenResultMessage
332
+ | ScrollElementResultMessage
333
+ | OpenUrlResultMessage;
334
+
136
335
  type ServerMessage =
137
336
  | ScreenshotResponse
138
337
  | ScreenshotErrorResponse
139
338
  | AssetResultResponse
339
+ | KnownCommandResultMessage
140
340
  | { type: string; [key: string]: unknown };
141
341
 
342
+ type CommandRequestMap = {
343
+ screenshot: {};
344
+ getElementTree: {};
345
+ findElement: { selector: AndroidSelector; limit?: number };
346
+ tap: AndroidElementTarget;
347
+ setText: { text: string } & AndroidElementTarget;
348
+ pressKey: { keyName?: string; key?: string; modifiers?: string[] };
349
+ scrollScreen: { direction: ScrollDirection; amount?: number };
350
+ scrollElement: AndroidElementTarget & { direction: ScrollDirection; amount?: number };
351
+ openUrl: { url: string };
352
+ };
353
+
354
+ type CommandResultMap = {
355
+ screenshot: ScreenshotData;
356
+ getElementTree: ElementTreeData;
357
+ findElement: FindElementResult;
358
+ tap: TapResult;
359
+ setText: SetTextResult;
360
+ pressKey: PressKeyResult;
361
+ scrollScreen: ScrollResult;
362
+ scrollElement: ScrollResult;
363
+ openUrl: OpenUrlResult;
364
+ };
365
+
366
+ type PendingRequest<T> = {
367
+ resolve: (value: T) => void;
368
+ reject: (reason: Error) => void;
369
+ timeout: NodeJS.Timeout;
370
+ };
371
+
142
372
  /**
143
373
  * Creates a client for interacting with a Limbar instance
144
374
  * @param options Configuration options including webrtcUrl, token and log level
@@ -158,21 +388,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
158
388
  let intentionalDisconnect = false;
159
389
  let lastError: string | undefined;
160
390
 
161
- const screenshotRequests: Map<
162
- string,
163
- {
164
- resolver: (value: ScreenshotData | PromiseLike<ScreenshotData>) => void;
165
- rejecter: (reason?: any) => void;
166
- }
167
- > = new Map();
168
-
169
- const assetRequests: Map<
170
- string,
171
- {
172
- resolver: (value: void | PromiseLike<void>) => void;
173
- rejecter: (reason?: any) => void;
174
- }
175
- > = new Map();
391
+ const pendingRequests: Map<string, PendingRequest<unknown>> = new Map();
392
+ const pendingAssetRequestsByUrl: Map<string, Array<PendingRequest<void>>> = new Map();
176
393
 
177
394
  const stateChangeCallbacks: Set<ConnectionStateCallback> = new Set();
178
395
 
@@ -207,10 +424,18 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
207
424
  };
208
425
 
209
426
  const failPendingRequests = (reason: string): void => {
210
- screenshotRequests.forEach((request) => request.rejecter(new Error(reason)));
211
- screenshotRequests.clear();
212
- assetRequests.forEach((request) => request.rejecter(new Error(reason)));
213
- assetRequests.clear();
427
+ pendingRequests.forEach((request) => {
428
+ clearTimeout(request.timeout);
429
+ request.reject(new Error(reason));
430
+ });
431
+ pendingRequests.clear();
432
+ pendingAssetRequestsByUrl.forEach((requests) => {
433
+ requests.forEach((request) => {
434
+ clearTimeout(request.timeout);
435
+ request.reject(new Error(reason));
436
+ });
437
+ });
438
+ pendingAssetRequestsByUrl.clear();
214
439
  };
215
440
 
216
441
  const cleanup = (): void => {
@@ -232,10 +457,109 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
232
457
  };
233
458
 
234
459
  let pingInterval: NodeJS.Timeout | undefined;
460
+ let requestCounter = 0;
235
461
 
236
462
  return new Promise<InstanceClient>((resolveConnection, rejectConnection) => {
237
463
  let hasResolved = false;
238
464
 
465
+ const nextRequestId = (prefix: string): string => {
466
+ requestCounter += 1;
467
+ return `${prefix}-${Date.now()}-${requestCounter}`;
468
+ };
469
+
470
+ const resolvePendingRequest = <T>(id: string, value: T): void => {
471
+ const request = pendingRequests.get(id) as PendingRequest<T> | undefined;
472
+ if (!request) {
473
+ logger.debug(`Received response for unknown/already-settled request: ${id}`);
474
+ return;
475
+ }
476
+ clearTimeout(request.timeout);
477
+ pendingRequests.delete(id);
478
+ request.resolve(value);
479
+ };
480
+
481
+ const rejectPendingRequest = (id: string, error: Error): void => {
482
+ const request = pendingRequests.get(id);
483
+ if (!request) {
484
+ logger.debug(`Received error for unknown/already-settled request: ${id}`);
485
+ return;
486
+ }
487
+ clearTimeout(request.timeout);
488
+ pendingRequests.delete(id);
489
+ request.reject(error);
490
+ };
491
+
492
+ const extractErrorMessage = (message: ServerMessage): string | undefined => {
493
+ if ('message' in message && typeof message.message === 'string') {
494
+ return message.message;
495
+ }
496
+ if ('error' in message && message.error && typeof message.error === 'object') {
497
+ const obj = message.error as CommandError;
498
+ if (typeof obj.message === 'string' && obj.message) {
499
+ return obj.message;
500
+ }
501
+ if (typeof obj.code === 'string' && obj.code) {
502
+ return obj.code;
503
+ }
504
+ // Presence of an error object itself is treated as failure, even if message/code are absent.
505
+ return `Server returned ${String(message.type)} with an error payload but no error message/code`;
506
+ }
507
+ return undefined;
508
+ };
509
+
510
+ const isKnownCommandResultMessage = (message: ServerMessage): message is KnownCommandResultMessage => {
511
+ if (!('type' in message) || typeof message.type !== 'string') {
512
+ return false;
513
+ }
514
+ switch (message.type) {
515
+ case 'screenshotResult':
516
+ case 'getElementTreeResult':
517
+ case 'findElementResult':
518
+ case 'tapResult':
519
+ case 'setTextResult':
520
+ case 'pressKeyResult':
521
+ case 'scrollScreenResult':
522
+ case 'scrollElementResult':
523
+ case 'openUrlResult':
524
+ return 'id' in message && typeof message.id === 'string';
525
+ default:
526
+ return false;
527
+ }
528
+ };
529
+
530
+ const sendRequest = async <K extends keyof CommandRequestMap>(
531
+ type: K,
532
+ params: CommandRequestMap[K],
533
+ timeoutMs: number = 30000,
534
+ ): Promise<CommandResultMap[K]> => {
535
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
536
+ return Promise.reject(new Error('WebSocket is not connected or connection is not open.'));
537
+ }
538
+ const id = nextRequestId('ts-client');
539
+ const command =
540
+ Object.keys(params).length > 0 ? { type, id, ...params, payload: params } : { type, id };
541
+ return new Promise<CommandResultMap[K]>((resolve, reject) => {
542
+ const timeout = setTimeout(() => {
543
+ if (pendingRequests.has(id)) {
544
+ pendingRequests.delete(id);
545
+ reject(new Error(`Request ${type} timed out`));
546
+ }
547
+ }, timeoutMs);
548
+ pendingRequests.set(id, {
549
+ resolve: (value: unknown) => resolve(value as CommandResultMap[K]),
550
+ reject: (reason: Error) => reject(reason),
551
+ timeout,
552
+ });
553
+ ws!.send(JSON.stringify(command), (err?: Error) => {
554
+ if (err) {
555
+ clearTimeout(timeout);
556
+ pendingRequests.delete(id);
557
+ reject(err);
558
+ }
559
+ });
560
+ });
561
+ };
562
+
239
563
  // Reconnection logic with exponential backoff
240
564
  const scheduleReconnect = (): void => {
241
565
  if (intentionalDisconnect) {
@@ -290,20 +614,30 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
290
614
  logger.warn('Received invalid screenshot message:', message);
291
615
  break;
292
616
  }
293
-
294
617
  const screenshotMessage = message as ScreenshotResponse;
295
- const request = screenshotRequests.get(screenshotMessage.id);
296
-
297
- if (!request) {
298
- logger.warn(
299
- `Received screenshot data for unknown or already handled session: ${screenshotMessage.id}`,
618
+ logger.debug(`Received screenshot data URI for request ${screenshotMessage.id}.`);
619
+ resolvePendingRequest<ScreenshotData>(screenshotMessage.id, {
620
+ dataUri: screenshotMessage.dataUri,
621
+ });
622
+ break;
623
+ }
624
+ case 'screenshotResult': {
625
+ const resultMessage = message as ScreenshotResultMessage;
626
+ const errorMessage = extractErrorMessage(resultMessage);
627
+ if (errorMessage) {
628
+ rejectPendingRequest(resultMessage.id, new Error(errorMessage));
629
+ break;
630
+ }
631
+ const dataUri =
632
+ typeof resultMessage.payload?.dataUri === 'string' ? resultMessage.payload.dataUri : '';
633
+ if (!dataUri) {
634
+ rejectPendingRequest(
635
+ resultMessage.id,
636
+ new Error('Received screenshotResult without payload.dataUri'),
300
637
  );
301
638
  break;
302
639
  }
303
-
304
- logger.debug(`Received screenshot data URI for session ${screenshotMessage.id}.`);
305
- request.resolver({ dataUri: screenshotMessage.dataUri });
306
- screenshotRequests.delete(screenshotMessage.id);
640
+ resolvePendingRequest<ScreenshotData>(resultMessage.id, { dataUri });
307
641
  break;
308
642
  }
309
643
  case 'screenshotError': {
@@ -311,50 +645,55 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
311
645
  logger.warn('Received invalid screenshot error message:', message);
312
646
  break;
313
647
  }
314
-
315
648
  const errorMessage = message as ScreenshotErrorResponse;
316
- const request = screenshotRequests.get(errorMessage.id);
317
-
318
- if (!request) {
319
- logger.warn(
320
- `Received screenshot error for unknown or already handled session: ${errorMessage.id}`,
321
- );
322
- break;
323
- }
324
-
325
649
  logger.error(
326
- `Server reported an error capturing screenshot for session ${errorMessage.id}:`,
650
+ `Server reported an error capturing screenshot for request ${errorMessage.id}:`,
327
651
  errorMessage.message,
328
652
  );
329
- request.rejecter(new Error(errorMessage.message));
330
- screenshotRequests.delete(errorMessage.id);
653
+ rejectPendingRequest(errorMessage.id, new Error(errorMessage.message));
331
654
  break;
332
655
  }
333
656
  case 'assetResult': {
334
657
  logger.debug('Received assetResult:', message);
335
- const request = assetRequests.get(message.url as string);
336
- if (!request) {
658
+ const url = message.url as string;
659
+ const queue = pendingAssetRequestsByUrl.get(url);
660
+ if (!queue || queue.length === 0) {
337
661
  logger.warn(`Received assetResult for unknown or already handled url: ${message.url}`);
338
662
  break;
339
663
  }
664
+ const request = queue.shift()!;
665
+ if (queue.length === 0) {
666
+ pendingAssetRequestsByUrl.delete(url);
667
+ } else {
668
+ pendingAssetRequestsByUrl.set(url, queue);
669
+ }
670
+ clearTimeout(request.timeout);
340
671
  if (message.result === 'success') {
341
672
  logger.debug('Asset result is success');
342
- request.resolver();
343
- assetRequests.delete(message.url as string);
673
+ request.resolve();
344
674
  break;
345
675
  }
346
- const errorMessage =
676
+ const assetErrorMessage =
347
677
  typeof message.message === 'string' && message.message ?
348
678
  message.message
349
679
  : `Asset processing failed: ${JSON.stringify(message)}`;
350
- logger.debug('Asset result is failure', errorMessage);
351
- request.rejecter(new Error(errorMessage));
352
- assetRequests.delete(message.url as string);
680
+ logger.debug('Asset result is failure', assetErrorMessage);
681
+ request.reject(new Error(assetErrorMessage));
353
682
  break;
354
683
  }
355
- default:
684
+ default: {
685
+ if (isKnownCommandResultMessage(message)) {
686
+ const err = extractErrorMessage(message);
687
+ if (err) {
688
+ rejectPendingRequest(message.id, new Error(err));
689
+ } else {
690
+ resolvePendingRequest(message.id, message.payload ?? message);
691
+ }
692
+ break;
693
+ }
356
694
  logger.warn(`Received unexpected message type: ${message.type}`);
357
695
  break;
696
+ }
358
697
  }
359
698
  });
360
699
 
@@ -410,6 +749,14 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
410
749
  hasResolved = true;
411
750
  resolveConnection({
412
751
  screenshot,
752
+ getElementTree,
753
+ findElement,
754
+ tap,
755
+ setText,
756
+ pressKey,
757
+ scrollScreen,
758
+ scrollElement,
759
+ openUrl,
413
760
  disconnect,
414
761
  startAdbTunnel,
415
762
  sendAsset,
@@ -421,45 +768,82 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
421
768
  };
422
769
 
423
770
  const screenshot = async (): Promise<ScreenshotData> => {
424
- if (!ws || ws.readyState !== WebSocket.OPEN) {
425
- return Promise.reject(new Error('WebSocket is not connected or connection is not open.'));
426
- }
771
+ return sendRequest('screenshot', {});
772
+ };
427
773
 
428
- const id = 'ts-client-' + Date.now();
429
- const screenshotRequest: ScreenshotRequest = {
430
- type: 'screenshot',
431
- id,
774
+ const getElementTree = async (): Promise<ElementTreeData> => {
775
+ const result = await sendRequest('getElementTree', {});
776
+ return {
777
+ xml: typeof result.xml === 'string' ? result.xml : '',
778
+ nodes: Array.isArray(result.nodes) ? result.nodes : [],
432
779
  };
780
+ };
433
781
 
434
- return new Promise<ScreenshotData>((resolve, reject) => {
435
- logger.debug('Sending screenshot request:', screenshotRequest);
436
- ws!.send(JSON.stringify(screenshotRequest), (err?: Error) => {
437
- if (err) {
438
- logger.error('Failed to send screenshot request:', err);
439
- reject(err);
440
- }
441
- });
782
+ const findElement = async (selector: AndroidSelector, limit = 20): Promise<FindElementResult> => {
783
+ const result = await sendRequest('findElement', { selector, limit });
784
+ const elements = Array.isArray(result.elements) ? result.elements : [];
785
+ return {
786
+ elements,
787
+ count: typeof result.count === 'number' ? result.count : elements.length,
788
+ };
789
+ };
442
790
 
443
- const timeout = setTimeout(() => {
444
- if (screenshotRequests.has(id)) {
445
- logger.error(`Screenshot request timed out for session ${id}`);
446
- screenshotRequests.get(id)?.rejecter(new Error('Screenshot request timed out'));
447
- screenshotRequests.delete(id);
448
- }
449
- }, 30000);
450
- screenshotRequests.set(id, {
451
- resolver: (value: ScreenshotData | PromiseLike<ScreenshotData>) => {
452
- clearTimeout(timeout);
453
- resolve(value);
454
- screenshotRequests.delete(id);
455
- },
456
- rejecter: (reason?: any) => {
457
- clearTimeout(timeout);
458
- reject(reason);
459
- screenshotRequests.delete(id);
460
- },
461
- });
791
+ const tap = async (target: AndroidElementTarget): Promise<TapResult> => {
792
+ const result = await sendRequest('tap', target);
793
+ return {
794
+ x: Number(result.x ?? 0),
795
+ y: Number(result.y ?? 0),
796
+ };
797
+ };
798
+
799
+ const setText = async (
800
+ target: AndroidElementTarget | undefined,
801
+ text: string,
802
+ ): Promise<SetTextResult> => {
803
+ const payload: CommandRequestMap['setText'] = { text };
804
+ if (target?.selector) payload.selector = target.selector;
805
+ if (typeof target?.x === 'number') payload.x = target.x;
806
+ if (typeof target?.y === 'number') payload.y = target.y;
807
+ const result = await sendRequest('setText', payload);
808
+ return {
809
+ textLength: Number(result.textLength ?? text.length),
810
+ };
811
+ };
812
+
813
+ const pressKey = async (key: string, modifiers?: string[]): Promise<PressKeyResult> => {
814
+ const payload: CommandRequestMap['pressKey'] = {
815
+ keyName: key,
816
+ ...(modifiers ? { modifiers } : {}),
817
+ };
818
+ const result = await sendRequest('pressKey', payload);
819
+ return {
820
+ key: typeof result.key === 'string' ? result.key : String(key),
821
+ };
822
+ };
823
+
824
+ const scrollScreen = async (direction: ScrollDirection, amount = 6): Promise<ScrollResult> => {
825
+ const result = await sendRequest('scrollScreen', { direction, amount });
826
+ return result;
827
+ };
828
+
829
+ const scrollElement = async (
830
+ target: AndroidElementTarget,
831
+ direction: ScrollDirection,
832
+ amount = 6,
833
+ ): Promise<ScrollResult> => {
834
+ const result = await sendRequest('scrollElement', {
835
+ ...target,
836
+ direction,
837
+ amount,
462
838
  });
839
+ return result;
840
+ };
841
+
842
+ const openUrl = async (url: string): Promise<OpenUrlResult> => {
843
+ const result = await sendRequest('openUrl', { url });
844
+ return {
845
+ url: typeof result.url === 'string' ? result.url : url,
846
+ };
463
847
  };
464
848
 
465
849
  const disconnect = (): void => {
@@ -518,13 +902,49 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
518
902
  type: 'asset',
519
903
  url,
520
904
  };
521
- ws.send(JSON.stringify(assetRequest), (err?: Error) => {
522
- if (err) {
523
- logger.error('Failed to send asset request:', err);
524
- }
525
- });
526
905
  return new Promise<void>((resolve, reject) => {
527
- assetRequests.set(url, { resolver: resolve, rejecter: reject });
906
+ let request: PendingRequest<void>;
907
+ const timeout = setTimeout(() => {
908
+ const queue = pendingAssetRequestsByUrl.get(url);
909
+ if (!queue) {
910
+ return;
911
+ }
912
+ const idx = queue.indexOf(request);
913
+ if (idx >= 0) {
914
+ queue.splice(idx, 1);
915
+ reject(new Error(`Request asset timed out for url: ${url}`));
916
+ }
917
+ if (queue.length === 0) {
918
+ pendingAssetRequestsByUrl.delete(url);
919
+ } else {
920
+ pendingAssetRequestsByUrl.set(url, queue);
921
+ }
922
+ }, 30000);
923
+ request = {
924
+ resolve,
925
+ reject: (reason: Error) => reject(reason),
926
+ timeout,
927
+ };
928
+ const queue = pendingAssetRequestsByUrl.get(url) ?? [];
929
+ queue.push(request);
930
+ pendingAssetRequestsByUrl.set(url, queue);
931
+ ws!.send(JSON.stringify(assetRequest), (err?: Error) => {
932
+ if (err) {
933
+ clearTimeout(timeout);
934
+ const queued = pendingAssetRequestsByUrl.get(url) ?? [];
935
+ const idx = queued.indexOf(request);
936
+ if (idx >= 0) {
937
+ queued.splice(idx, 1);
938
+ }
939
+ if (queued.length === 0) {
940
+ pendingAssetRequestsByUrl.delete(url);
941
+ } else {
942
+ pendingAssetRequestsByUrl.set(url, queued);
943
+ }
944
+ logger.error('Failed to send asset request:', err);
945
+ reject(err);
946
+ }
947
+ });
528
948
  });
529
949
  };
530
950