@limrun/api 0.17.5 → 0.18.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.
- package/CHANGELOG.md +8 -0
- package/README.md +31 -0
- package/ios-client.d.mts +143 -9
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +143 -9
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +171 -191
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +171 -191
- package/ios-client.mjs.map +1 -1
- package/package.json +1 -1
- package/src/ios-client.ts +391 -294
- package/src/version.ts +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- package/version.mjs +1 -1
package/src/ios-client.ts
CHANGED
|
@@ -25,16 +25,187 @@ export interface SimctlExecutionEvents {
|
|
|
25
25
|
error: (error: Error) => void;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Accessibility Selector - used for element-based operations
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Selector criteria for finding accessibility elements.
|
|
34
|
+
* All non-undefined fields must match for an element to be selected.
|
|
35
|
+
*/
|
|
36
|
+
export type AccessibilitySelector = {
|
|
37
|
+
/** Match by AXUniqueId (accessibilityIdentifier) - exact match */
|
|
38
|
+
accessibilityId?: string;
|
|
39
|
+
/** Match by AXLabel - exact match */
|
|
40
|
+
label?: string;
|
|
41
|
+
/** Match by AXLabel - contains (case-insensitive) */
|
|
42
|
+
labelContains?: string;
|
|
43
|
+
/** Match by element type/role (e.g., "Button", "TextField") - case-insensitive */
|
|
44
|
+
elementType?: string;
|
|
45
|
+
/** Match by title - exact match */
|
|
46
|
+
title?: string;
|
|
47
|
+
/** Match by title - contains (case-insensitive) */
|
|
48
|
+
titleContains?: string;
|
|
49
|
+
/** Match by AXValue - exact match */
|
|
50
|
+
value?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A point on the screen for accessibility queries
|
|
55
|
+
*/
|
|
56
|
+
export type AccessibilityPoint = {
|
|
57
|
+
x: number;
|
|
58
|
+
y: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Result Types
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
export type ScreenshotData = {
|
|
66
|
+
/** Base64-encoded JPEG image data */
|
|
67
|
+
base64: string;
|
|
68
|
+
/** Width in points (for tap coordinates) */
|
|
69
|
+
width: number;
|
|
70
|
+
/** Height in points (for tap coordinates) */
|
|
71
|
+
height: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type TapElementResult = {
|
|
75
|
+
elementLabel?: string;
|
|
76
|
+
elementType?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type ElementResult = {
|
|
80
|
+
elementLabel?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type InstalledApp = {
|
|
84
|
+
bundleId: string;
|
|
85
|
+
name: string;
|
|
86
|
+
installType: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type LsofEntry = {
|
|
90
|
+
kind: 'unix';
|
|
91
|
+
path: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type AppInstallationResult = {
|
|
95
|
+
/** The URL the app was installed from */
|
|
96
|
+
url: string;
|
|
97
|
+
/** Bundle ID of the installed app */
|
|
98
|
+
bundleId: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type AppInstallationOptions = {
|
|
102
|
+
/** MD5 hash for caching - if provided and matches cached version, skips download */
|
|
103
|
+
md5?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Launch mode after installation:
|
|
106
|
+
* - 'ForegroundIfRunning': Bring to foreground if already running, otherwise launch
|
|
107
|
+
* - 'RelaunchIfRunning': Kill and relaunch if already running
|
|
108
|
+
* - 'FailIfRunning': Fail if the app is already running
|
|
109
|
+
* - undefined: Don't launch after installation
|
|
110
|
+
*/
|
|
111
|
+
launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning' | 'FailIfRunning';
|
|
112
|
+
};
|
|
113
|
+
|
|
28
114
|
/**
|
|
29
115
|
* A client for interacting with a Limrun iOS instance
|
|
30
116
|
*/
|
|
31
117
|
export type InstanceClient = {
|
|
32
118
|
/**
|
|
33
119
|
* Take a screenshot of the current screen
|
|
34
|
-
* @returns A promise that resolves to the screenshot data
|
|
120
|
+
* @returns A promise that resolves to the screenshot data with base64 image and dimensions
|
|
35
121
|
*/
|
|
36
122
|
screenshot: () => Promise<ScreenshotData>;
|
|
37
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Get the element tree (accessibility hierarchy) of the current screen
|
|
126
|
+
* @param point Optional point to get the element at that specific location
|
|
127
|
+
* @returns A promise that resolves to the JSON string of the accessibility tree
|
|
128
|
+
*/
|
|
129
|
+
elementTree: (point?: AccessibilityPoint) => Promise<string>;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Tap at the specified coordinates
|
|
133
|
+
* @param x X coordinate in points
|
|
134
|
+
* @param y Y coordinate in points
|
|
135
|
+
*/
|
|
136
|
+
tap: (x: number, y: number) => Promise<void>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Tap an accessibility element by selector
|
|
140
|
+
* @param selector The selector criteria to find the element
|
|
141
|
+
* @returns Information about the tapped element
|
|
142
|
+
*/
|
|
143
|
+
tapElement: (selector: AccessibilitySelector) => Promise<TapElementResult>;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Increment an accessibility element (useful for sliders, steppers, etc.)
|
|
147
|
+
* @param selector The selector criteria to find the element
|
|
148
|
+
* @returns Information about the incremented element
|
|
149
|
+
*/
|
|
150
|
+
incrementElement: (selector: AccessibilitySelector) => Promise<ElementResult>;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Decrement an accessibility element (useful for sliders, steppers, etc.)
|
|
154
|
+
* @param selector The selector criteria to find the element
|
|
155
|
+
* @returns Information about the decremented element
|
|
156
|
+
*/
|
|
157
|
+
decrementElement: (selector: AccessibilitySelector) => Promise<ElementResult>;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Set the value of an accessibility element (useful for text fields, etc.)
|
|
161
|
+
* This is much faster than typing character by character.
|
|
162
|
+
* @param text The text value to set
|
|
163
|
+
* @param selector The selector criteria to find the element
|
|
164
|
+
* @returns Information about the modified element
|
|
165
|
+
*/
|
|
166
|
+
setElementValue: (text: string, selector: AccessibilitySelector) => Promise<ElementResult>;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Type text into the currently focused input field
|
|
170
|
+
* @param text The text to type
|
|
171
|
+
* @param pressEnter If true, press Enter after typing
|
|
172
|
+
*/
|
|
173
|
+
typeText: (text: string, pressEnter?: boolean) => Promise<void>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Press a key on the keyboard, optionally with modifiers
|
|
177
|
+
* @param key The key to press (e.g., 'a', 'enter', 'backspace', 'up', 'f1')
|
|
178
|
+
* @param modifiers Optional modifier keys (e.g., ['shift'], ['command', 'shift'])
|
|
179
|
+
*/
|
|
180
|
+
pressKey: (key: string, modifiers?: string[]) => Promise<void>;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Launch an installed app by bundle identifier
|
|
184
|
+
* @param bundleId Bundle identifier of the app to launch
|
|
185
|
+
*/
|
|
186
|
+
launchApp: (bundleId: string) => Promise<void>;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* List installed apps on the simulator
|
|
190
|
+
* @returns Array of installed apps with bundleId, name, and installType
|
|
191
|
+
*/
|
|
192
|
+
listApps: () => Promise<InstalledApp[]>;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Open a URL in the simulator (web URLs open in Safari, deep links open corresponding apps)
|
|
196
|
+
* @param url The URL to open
|
|
197
|
+
*/
|
|
198
|
+
openUrl: (url: string) => Promise<void>;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Install an app from a URL (supports .ipa or .app files, optionally zipped)
|
|
202
|
+
* @param url The URL to download the app from
|
|
203
|
+
* @param options Optional installation options (md5 for caching, launchMode)
|
|
204
|
+
* @returns The installation result with bundle ID on success
|
|
205
|
+
* @throws Error if installation fails (e.g., invalid app, download failure)
|
|
206
|
+
*/
|
|
207
|
+
installApp: (url: string, options?: AppInstallationOptions) => Promise<AppInstallationResult>;
|
|
208
|
+
|
|
38
209
|
/**
|
|
39
210
|
* Disconnect from the Limrun instance
|
|
40
211
|
*/
|
|
@@ -109,11 +280,6 @@ export type InstanceClient = {
|
|
|
109
280
|
lsof: () => Promise<LsofEntry[]>;
|
|
110
281
|
};
|
|
111
282
|
|
|
112
|
-
export type LsofEntry = {
|
|
113
|
-
kind: 'unix';
|
|
114
|
-
path: string;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
283
|
/**
|
|
118
284
|
* Controls the verbosity of logging in the client
|
|
119
285
|
*/
|
|
@@ -153,68 +319,50 @@ export type InstanceClientOptions = {
|
|
|
153
319
|
maxReconnectDelay?: number;
|
|
154
320
|
};
|
|
155
321
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
type ScreenshotResponse = {
|
|
162
|
-
type: 'screenshot';
|
|
163
|
-
dataUri: string;
|
|
164
|
-
id: string;
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
type ScreenshotData = {
|
|
168
|
-
dataUri: string;
|
|
169
|
-
};
|
|
322
|
+
// ============================================================================
|
|
323
|
+
// Internal Types - Message Protocol
|
|
324
|
+
// ============================================================================
|
|
170
325
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
326
|
+
/**
|
|
327
|
+
* Generic pending request tracker
|
|
328
|
+
*/
|
|
329
|
+
type PendingRequest<T> = {
|
|
330
|
+
resolve: (value: T) => void;
|
|
331
|
+
reject: (reason: Error) => void;
|
|
332
|
+
timeout: NodeJS.Timeout;
|
|
175
333
|
};
|
|
176
334
|
|
|
335
|
+
// Simctl uses streaming, so it's handled separately
|
|
177
336
|
type SimctlRequest = {
|
|
178
337
|
type: 'simctl';
|
|
179
338
|
id: string;
|
|
180
339
|
args: string[];
|
|
181
340
|
};
|
|
182
341
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
exitCode?: number;
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
type SimctlErrorResponse = {
|
|
192
|
-
type: 'simctlError';
|
|
193
|
-
id: string;
|
|
194
|
-
message: string;
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
type ListOpenFilesRequest = {
|
|
198
|
-
type: 'listOpenFiles';
|
|
199
|
-
id: string;
|
|
200
|
-
kind: 'unix';
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
type ListOpenFilesResponse = {
|
|
204
|
-
type: 'listOpenFilesResult';
|
|
342
|
+
/**
|
|
343
|
+
* Generic server response with optional error
|
|
344
|
+
*/
|
|
345
|
+
type ServerResponse = {
|
|
346
|
+
type: string;
|
|
205
347
|
id: string;
|
|
206
|
-
files?: LsofEntry[];
|
|
207
348
|
error?: string;
|
|
349
|
+
// Response-specific fields
|
|
350
|
+
base64?: string;
|
|
351
|
+
width?: number;
|
|
352
|
+
height?: number;
|
|
353
|
+
json?: string;
|
|
354
|
+
elementLabel?: string;
|
|
355
|
+
elementType?: string;
|
|
356
|
+
apps?: string;
|
|
357
|
+
url?: string;
|
|
358
|
+
bundleId?: string;
|
|
359
|
+
files?: LsofEntry[];
|
|
360
|
+
// Simctl streaming fields
|
|
361
|
+
stdout?: string;
|
|
362
|
+
stderr?: string;
|
|
363
|
+
exitCode?: number;
|
|
208
364
|
};
|
|
209
365
|
|
|
210
|
-
type ServerMessage =
|
|
211
|
-
| ScreenshotResponse
|
|
212
|
-
| ScreenshotErrorResponse
|
|
213
|
-
| SimctlStreamResponse
|
|
214
|
-
| SimctlErrorResponse
|
|
215
|
-
| ListOpenFilesResponse
|
|
216
|
-
| { type: string; [key: string]: unknown };
|
|
217
|
-
|
|
218
366
|
/**
|
|
219
367
|
* Handle for a running simctl command execution.
|
|
220
368
|
*
|
|
@@ -408,22 +556,10 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
408
556
|
let intentionalDisconnect = false;
|
|
409
557
|
let lastError: string | undefined;
|
|
410
558
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
{
|
|
414
|
-
resolver: (value: ScreenshotData | PromiseLike<ScreenshotData>) => void;
|
|
415
|
-
rejecter: (reason?: any) => void;
|
|
416
|
-
}
|
|
417
|
-
> = new Map();
|
|
418
|
-
|
|
419
|
-
const lsofRequests: Map<
|
|
420
|
-
string,
|
|
421
|
-
{
|
|
422
|
-
resolver: (value: LsofEntry[] | PromiseLike<LsofEntry[]>) => void;
|
|
423
|
-
rejecter: (reason?: any) => void;
|
|
424
|
-
}
|
|
425
|
-
> = new Map();
|
|
559
|
+
// Centralized pending requests map - handles all request/response patterns
|
|
560
|
+
const pendingRequests: Map<string, PendingRequest<any>> = new Map();
|
|
426
561
|
|
|
562
|
+
// Simctl uses streaming, so it needs separate handling
|
|
427
563
|
const simctlExecutions: Map<string, SimctlExecution> = new Map();
|
|
428
564
|
|
|
429
565
|
const stateChangeCallbacks: Set<ConnectionStateCallback> = new Set();
|
|
@@ -459,11 +595,11 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
459
595
|
};
|
|
460
596
|
|
|
461
597
|
const failPendingRequests = (reason: string): void => {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
598
|
+
pendingRequests.forEach((request) => {
|
|
599
|
+
clearTimeout(request.timeout);
|
|
600
|
+
request.reject(new Error(reason));
|
|
601
|
+
});
|
|
602
|
+
pendingRequests.clear();
|
|
467
603
|
|
|
468
604
|
simctlExecutions.forEach((execution) => execution._handleError(new Error(reason)));
|
|
469
605
|
simctlExecutions.clear();
|
|
@@ -523,6 +659,73 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
523
659
|
}, currentDelay);
|
|
524
660
|
};
|
|
525
661
|
|
|
662
|
+
// Generate unique request ID
|
|
663
|
+
const generateId = (): string => {
|
|
664
|
+
return `ts-client-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// Generic request sender with timeout and response handling
|
|
668
|
+
const sendRequest = <T>(
|
|
669
|
+
type: string,
|
|
670
|
+
params: Record<string, unknown> = {},
|
|
671
|
+
timeoutMs: number = 30000,
|
|
672
|
+
): Promise<T> => {
|
|
673
|
+
return new Promise((resolve, reject) => {
|
|
674
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
675
|
+
reject(new Error('WebSocket is not connected or connection is not open.'));
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const id = generateId();
|
|
680
|
+
const timeout = setTimeout(() => {
|
|
681
|
+
pendingRequests.delete(id);
|
|
682
|
+
reject(new Error(`Request ${type} timed out`));
|
|
683
|
+
}, timeoutMs);
|
|
684
|
+
|
|
685
|
+
pendingRequests.set(id, { resolve, reject, timeout });
|
|
686
|
+
|
|
687
|
+
const request = { type, id, ...params };
|
|
688
|
+
logger.debug('Sending request:', request);
|
|
689
|
+
|
|
690
|
+
ws.send(JSON.stringify(request), (err?: Error) => {
|
|
691
|
+
if (err) {
|
|
692
|
+
clearTimeout(timeout);
|
|
693
|
+
pendingRequests.delete(id);
|
|
694
|
+
logger.error(`Failed to send ${type} request:`, err);
|
|
695
|
+
reject(err);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
// Response handlers - transform raw responses to typed results
|
|
702
|
+
const responseHandlers: Record<string, (msg: ServerResponse) => unknown> = {
|
|
703
|
+
screenshotResult: (msg) => ({
|
|
704
|
+
base64: msg.base64!,
|
|
705
|
+
width: msg.width!,
|
|
706
|
+
height: msg.height!,
|
|
707
|
+
}),
|
|
708
|
+
elementTreeResult: (msg) => msg.json!,
|
|
709
|
+
tapResult: () => undefined,
|
|
710
|
+
tapElementResult: (msg) => ({
|
|
711
|
+
elementLabel: msg.elementLabel,
|
|
712
|
+
elementType: msg.elementType,
|
|
713
|
+
}),
|
|
714
|
+
incrementElementResult: (msg) => ({ elementLabel: msg.elementLabel }),
|
|
715
|
+
decrementElementResult: (msg) => ({ elementLabel: msg.elementLabel }),
|
|
716
|
+
setElementValueResult: (msg) => ({ elementLabel: msg.elementLabel }),
|
|
717
|
+
typeTextResult: () => undefined,
|
|
718
|
+
pressKeyResult: () => undefined,
|
|
719
|
+
launchAppResult: () => undefined,
|
|
720
|
+
listAppsResult: (msg) => JSON.parse(msg.apps || '[]') as InstalledApp[],
|
|
721
|
+
listOpenFilesResult: (msg) => msg.files || [],
|
|
722
|
+
openUrlResult: () => undefined,
|
|
723
|
+
appInstallationResult: (msg) => ({
|
|
724
|
+
url: msg.url || '',
|
|
725
|
+
bundleId: msg.bundleId || '',
|
|
726
|
+
}),
|
|
727
|
+
};
|
|
728
|
+
|
|
526
729
|
const setupWebSocket = (): void => {
|
|
527
730
|
cleanup();
|
|
528
731
|
updateConnectionState('connecting');
|
|
@@ -530,7 +733,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
530
733
|
ws = new WebSocket(endpointWebSocketUrl);
|
|
531
734
|
|
|
532
735
|
ws.on('message', (data: Data) => {
|
|
533
|
-
let message:
|
|
736
|
+
let message: ServerResponse;
|
|
534
737
|
try {
|
|
535
738
|
message = JSON.parse(data.toString());
|
|
536
739
|
} catch (e) {
|
|
@@ -538,163 +741,66 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
538
741
|
return;
|
|
539
742
|
}
|
|
540
743
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const screenshotMessage = message as ScreenshotResponse;
|
|
549
|
-
const request = screenshotRequests.get(screenshotMessage.id);
|
|
550
|
-
|
|
551
|
-
if (!request) {
|
|
552
|
-
logger.warn(
|
|
553
|
-
`Received screenshot data for unknown or already handled session: ${screenshotMessage.id}`,
|
|
554
|
-
);
|
|
555
|
-
break;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
logger.debug(`Received screenshot data URI for session ${screenshotMessage.id}.`);
|
|
559
|
-
request.resolver({ dataUri: screenshotMessage.dataUri });
|
|
560
|
-
screenshotRequests.delete(screenshotMessage.id);
|
|
561
|
-
break;
|
|
744
|
+
// Handle simctl streaming separately (it uses multiple messages per request)
|
|
745
|
+
if (message.type === 'simctlStream') {
|
|
746
|
+
const execution = simctlExecutions.get(message.id);
|
|
747
|
+
if (!execution) {
|
|
748
|
+
logger.warn(`Received simctl stream for unknown execution: ${message.id}`);
|
|
749
|
+
return;
|
|
562
750
|
}
|
|
563
|
-
case 'screenshotError': {
|
|
564
|
-
if (!('message' in message) || !('id' in message)) {
|
|
565
|
-
logger.warn('Received invalid screenshot error message:', message);
|
|
566
|
-
break;
|
|
567
|
-
}
|
|
568
751
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
logger.
|
|
574
|
-
`Received screenshot error for unknown or already handled session: ${errorMessage.id}`,
|
|
575
|
-
);
|
|
576
|
-
break;
|
|
752
|
+
if (message.stdout) {
|
|
753
|
+
try {
|
|
754
|
+
execution._handleStdout(Buffer.from(message.stdout, 'base64'));
|
|
755
|
+
} catch (err) {
|
|
756
|
+
logger.error('Failed to decode stdout data:', err);
|
|
577
757
|
}
|
|
578
|
-
|
|
579
|
-
logger.error(
|
|
580
|
-
`Server reported an error capturing screenshot for session ${errorMessage.id}:`,
|
|
581
|
-
errorMessage.message,
|
|
582
|
-
);
|
|
583
|
-
request.rejecter(new Error(errorMessage.message));
|
|
584
|
-
screenshotRequests.delete(errorMessage.id);
|
|
585
|
-
break;
|
|
586
758
|
}
|
|
587
|
-
case 'simctlStream': {
|
|
588
|
-
if (!('id' in message)) {
|
|
589
|
-
logger.warn('Received invalid simctl stream message:', message);
|
|
590
|
-
break;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const streamMessage = message as SimctlStreamResponse;
|
|
594
|
-
const execution = simctlExecutions.get(streamMessage.id);
|
|
595
759
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
760
|
+
if (message.stderr) {
|
|
761
|
+
try {
|
|
762
|
+
execution._handleStderr(Buffer.from(message.stderr, 'base64'));
|
|
763
|
+
} catch (err) {
|
|
764
|
+
logger.error('Failed to decode stderr data:', err);
|
|
601
765
|
}
|
|
602
|
-
|
|
603
|
-
// Handle stdout if present
|
|
604
|
-
if (streamMessage.stdout) {
|
|
605
|
-
try {
|
|
606
|
-
const stdoutBuffer = Buffer.from(streamMessage.stdout, 'base64');
|
|
607
|
-
execution._handleStdout(stdoutBuffer);
|
|
608
|
-
} catch (err) {
|
|
609
|
-
logger.error('Failed to decode stdout data:', err);
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// Handle stderr if present
|
|
614
|
-
if (streamMessage.stderr) {
|
|
615
|
-
try {
|
|
616
|
-
const stderrBuffer = Buffer.from(streamMessage.stderr, 'base64');
|
|
617
|
-
execution._handleStderr(stderrBuffer);
|
|
618
|
-
} catch (err) {
|
|
619
|
-
logger.error('Failed to decode stderr data:', err);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Handle exit code if present (final message)
|
|
624
|
-
if (streamMessage.exitCode !== undefined) {
|
|
625
|
-
logger.debug(
|
|
626
|
-
`Simctl execution ${streamMessage.id} completed with exit code ${streamMessage.exitCode}`,
|
|
627
|
-
);
|
|
628
|
-
execution._handleExit(streamMessage.exitCode);
|
|
629
|
-
simctlExecutions.delete(streamMessage.id);
|
|
630
|
-
}
|
|
631
|
-
break;
|
|
632
766
|
}
|
|
633
|
-
case 'simctlError': {
|
|
634
|
-
if (!('message' in message) || !('id' in message)) {
|
|
635
|
-
logger.warn('Received invalid simctl error message:', message);
|
|
636
|
-
break;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
const errorMessage = message as SimctlErrorResponse;
|
|
640
|
-
const execution = simctlExecutions.get(errorMessage.id);
|
|
641
767
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
break;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
logger.error(
|
|
650
|
-
`Server reported an error for simctl execution ${errorMessage.id}:`,
|
|
651
|
-
errorMessage.message,
|
|
652
|
-
);
|
|
653
|
-
execution._handleError(new Error(errorMessage.message));
|
|
654
|
-
simctlExecutions.delete(errorMessage.id);
|
|
655
|
-
break;
|
|
768
|
+
if (message.exitCode !== undefined) {
|
|
769
|
+
logger.debug(`Simctl execution ${message.id} completed with exit code ${message.exitCode}`);
|
|
770
|
+
execution._handleExit(message.exitCode);
|
|
771
|
+
simctlExecutions.delete(message.id);
|
|
656
772
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
logger.warn('Received invalid listOpenFilesResult message:', message);
|
|
660
|
-
break;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
const lsofMessage = message as ListOpenFilesResponse;
|
|
664
|
-
const request = lsofRequests.get(lsofMessage.id);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
665
775
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
776
|
+
// Handle all other request/response patterns generically
|
|
777
|
+
const request = pendingRequests.get(message.id);
|
|
778
|
+
if (!request) {
|
|
779
|
+
logger.debug(`Received response for unknown or already handled request: ${message.id}`);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
672
782
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
`Server reported an error listing open files for session ${lsofMessage.id}:`,
|
|
676
|
-
lsofMessage.error,
|
|
677
|
-
);
|
|
678
|
-
request.rejecter(new Error(lsofMessage.error));
|
|
679
|
-
lsofRequests.delete(lsofMessage.id);
|
|
680
|
-
break;
|
|
681
|
-
}
|
|
783
|
+
clearTimeout(request.timeout);
|
|
784
|
+
pendingRequests.delete(message.id);
|
|
682
785
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
786
|
+
// Check for error
|
|
787
|
+
if (message.error) {
|
|
788
|
+
logger.error(`Server error for ${message.type}: ${message.error}`);
|
|
789
|
+
request.reject(new Error(message.error));
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
689
792
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
793
|
+
// Use handler to transform response, or resolve with raw message
|
|
794
|
+
const handler = responseHandlers[message.type];
|
|
795
|
+
if (handler) {
|
|
796
|
+
try {
|
|
797
|
+
request.resolve(handler(message));
|
|
798
|
+
} catch (err) {
|
|
799
|
+
request.reject(err as Error);
|
|
694
800
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
801
|
+
} else {
|
|
802
|
+
logger.warn('Received unexpected message type:', message.type);
|
|
803
|
+
request.resolve(message);
|
|
698
804
|
}
|
|
699
805
|
});
|
|
700
806
|
|
|
@@ -749,6 +855,18 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
749
855
|
hasResolved = true;
|
|
750
856
|
resolveConnection({
|
|
751
857
|
screenshot,
|
|
858
|
+
elementTree,
|
|
859
|
+
tap,
|
|
860
|
+
tapElement,
|
|
861
|
+
incrementElement,
|
|
862
|
+
decrementElement,
|
|
863
|
+
setElementValue,
|
|
864
|
+
typeText,
|
|
865
|
+
pressKey,
|
|
866
|
+
launchApp,
|
|
867
|
+
listApps,
|
|
868
|
+
openUrl,
|
|
869
|
+
installApp,
|
|
752
870
|
disconnect,
|
|
753
871
|
getConnectionState,
|
|
754
872
|
onConnectionStateChange,
|
|
@@ -760,93 +878,72 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
760
878
|
});
|
|
761
879
|
};
|
|
762
880
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
}
|
|
881
|
+
// ========================================================================
|
|
882
|
+
// Client Methods - using centralized sendRequest
|
|
883
|
+
// ========================================================================
|
|
767
884
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
id,
|
|
772
|
-
};
|
|
885
|
+
const screenshot = (): Promise<ScreenshotData> => {
|
|
886
|
+
return sendRequest<ScreenshotData>('screenshot');
|
|
887
|
+
};
|
|
773
888
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
if (err) {
|
|
778
|
-
logger.error('Failed to send screenshot request:', err);
|
|
779
|
-
reject(err);
|
|
780
|
-
}
|
|
781
|
-
});
|
|
889
|
+
const elementTree = (point?: AccessibilityPoint): Promise<string> => {
|
|
890
|
+
return sendRequest<string>('elementTree', { point });
|
|
891
|
+
};
|
|
782
892
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
logger.error(`Screenshot request timed out for session ${id}`);
|
|
786
|
-
screenshotRequests.get(id)?.rejecter(new Error('Screenshot request timed out'));
|
|
787
|
-
screenshotRequests.delete(id);
|
|
788
|
-
}
|
|
789
|
-
}, 30000);
|
|
790
|
-
screenshotRequests.set(id, {
|
|
791
|
-
resolver: (value: ScreenshotData | PromiseLike<ScreenshotData>) => {
|
|
792
|
-
clearTimeout(timeout);
|
|
793
|
-
resolve(value);
|
|
794
|
-
screenshotRequests.delete(id);
|
|
795
|
-
},
|
|
796
|
-
rejecter: (reason?: any) => {
|
|
797
|
-
clearTimeout(timeout);
|
|
798
|
-
reject(reason);
|
|
799
|
-
screenshotRequests.delete(id);
|
|
800
|
-
},
|
|
801
|
-
});
|
|
802
|
-
});
|
|
893
|
+
const tap = (x: number, y: number): Promise<void> => {
|
|
894
|
+
return sendRequest<void>('tap', { x, y });
|
|
803
895
|
};
|
|
804
896
|
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
}
|
|
897
|
+
const tapElement = (selector: AccessibilitySelector): Promise<TapElementResult> => {
|
|
898
|
+
return sendRequest<TapElementResult>('tapElement', { selector });
|
|
899
|
+
};
|
|
809
900
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
id,
|
|
814
|
-
kind: 'unix',
|
|
815
|
-
};
|
|
901
|
+
const incrementElement = (selector: AccessibilitySelector): Promise<ElementResult> => {
|
|
902
|
+
return sendRequest<ElementResult>('incrementElement', { selector });
|
|
903
|
+
};
|
|
816
904
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
if (err) {
|
|
821
|
-
logger.error('Failed to send listOpenFiles request:', err);
|
|
822
|
-
reject(err);
|
|
823
|
-
}
|
|
824
|
-
});
|
|
905
|
+
const decrementElement = (selector: AccessibilitySelector): Promise<ElementResult> => {
|
|
906
|
+
return sendRequest<ElementResult>('decrementElement', { selector });
|
|
907
|
+
};
|
|
825
908
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
909
|
+
const setElementValue = (text: string, selector: AccessibilitySelector): Promise<ElementResult> => {
|
|
910
|
+
return sendRequest<ElementResult>('setElementValue', { text, selector });
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const typeText = (text: string, pressEnter?: boolean): Promise<void> => {
|
|
914
|
+
return sendRequest<void>('typeText', { text, pressEnter });
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
const pressKey = (key: string, modifiers?: string[]): Promise<void> => {
|
|
918
|
+
return sendRequest<void>('pressKey', { key, modifiers });
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
const launchApp = (bundleId: string): Promise<void> => {
|
|
922
|
+
return sendRequest<void>('launchApp', { bundleId });
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
const listApps = (): Promise<InstalledApp[]> => {
|
|
926
|
+
return sendRequest<InstalledApp[]>('listApps');
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const openUrl = (url: string): Promise<void> => {
|
|
930
|
+
return sendRequest<void>('openUrl', { url });
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
const installApp = (url: string, options?: AppInstallationOptions): Promise<AppInstallationResult> => {
|
|
934
|
+
return sendRequest<AppInstallationResult>('appInstallation', {
|
|
935
|
+
url,
|
|
936
|
+
md5: options?.md5,
|
|
937
|
+
launchMode: options?.launchMode,
|
|
845
938
|
});
|
|
846
939
|
};
|
|
847
940
|
|
|
941
|
+
const lsof = (): Promise<LsofEntry[]> => {
|
|
942
|
+
return sendRequest<LsofEntry[]>('listOpenFiles', { kind: 'unix' });
|
|
943
|
+
};
|
|
944
|
+
|
|
848
945
|
const simctl = (args: string[], opts: { disconnectOnExit?: boolean } = {}): SimctlExecution => {
|
|
849
|
-
const id =
|
|
946
|
+
const id = generateId();
|
|
850
947
|
|
|
851
948
|
const cancelCallback = () => {
|
|
852
949
|
// Clean up execution tracking
|