@loadmill/droid-cua 1.0.0 → 1.1.1
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/README.md +60 -12
- package/build/index.js +10 -2
- package/build/src/cli/app.js +38 -1
- package/build/src/cli/command-parser.js +1 -0
- package/build/src/cli/device-selector.js +195 -0
- package/build/src/commands/help.js +38 -6
- package/build/src/commands/index.js +2 -0
- package/build/src/commands/loadmill.js +87 -0
- package/build/src/core/execution-engine.js +2 -2
- package/build/src/device/actions.js +19 -78
- package/build/src/device/android/actions.js +81 -0
- package/build/src/device/android/connection.js +154 -0
- package/build/src/device/connection.js +51 -116
- package/build/src/device/factory.js +72 -0
- package/build/src/device/interface.js +50 -0
- package/build/src/device/ios/actions.js +117 -0
- package/build/src/device/ios/appium-client.js +207 -0
- package/build/src/device/ios/appium-server.js +101 -0
- package/build/src/device/ios/connection.js +280 -0
- package/build/src/device/loadmill.js +122 -0
- package/build/src/integrations/loadmill/client.js +151 -0
- package/build/src/integrations/loadmill/executor.js +152 -0
- package/build/src/integrations/loadmill/index.js +6 -0
- package/build/src/integrations/loadmill/interpreter.js +116 -0
- package/build/src/modes/execution-mode.js +71 -12
- package/package.json +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS Action Execution Module
|
|
3
|
+
*
|
|
4
|
+
* Executes CUA model actions on iOS Simulator via Appium.
|
|
5
|
+
*/
|
|
6
|
+
import * as appium from "./appium-client.js";
|
|
7
|
+
import { getActiveSession, getDevicePixelRatio } from "./connection.js";
|
|
8
|
+
import { logger } from "../../utils/logger.js";
|
|
9
|
+
/**
|
|
10
|
+
* Handle an action from the CUA model
|
|
11
|
+
* @param {string} simulatorId - The simulator UDID
|
|
12
|
+
* @param {object} action - The action to execute
|
|
13
|
+
* @param {number} scale - Scale factor for coordinates
|
|
14
|
+
* @param {object} context - Context with addOutput function
|
|
15
|
+
*/
|
|
16
|
+
export async function handleModelAction(simulatorId, action, scale = 1.0, context = null) {
|
|
17
|
+
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
18
|
+
const session = getActiveSession();
|
|
19
|
+
if (!session) {
|
|
20
|
+
throw new Error("No active iOS session");
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
switch (action.type) {
|
|
24
|
+
case "click": {
|
|
25
|
+
// Convert scaled coordinates to pixels, then to logical points for Appium
|
|
26
|
+
const dpr = getDevicePixelRatio();
|
|
27
|
+
const pixelX = Math.round(action.x / scale);
|
|
28
|
+
const pixelY = Math.round(action.y / scale);
|
|
29
|
+
const pointX = Math.round(pixelX / dpr);
|
|
30
|
+
const pointY = Math.round(pixelY / dpr);
|
|
31
|
+
addOutput({ type: "action", text: `Tapping at (${pointX}, ${pointY}) points` });
|
|
32
|
+
await appium.tap(session.sessionId, pointX, pointY);
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
case "type": {
|
|
36
|
+
addOutput({ type: "action", text: `Typing text: ${action.text}` });
|
|
37
|
+
await appium.type(session.sessionId, action.text);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
case "scroll": {
|
|
41
|
+
const dpr = getDevicePixelRatio();
|
|
42
|
+
const scrollX = Math.round((action.scroll_x / scale) / dpr);
|
|
43
|
+
const scrollY = Math.round((action.scroll_y / scale) / dpr);
|
|
44
|
+
addOutput({ type: "action", text: `Scrolling by (${scrollX}, ${scrollY}) points` });
|
|
45
|
+
// Start from center of screen (in logical points)
|
|
46
|
+
const centerX = 197; // Center of iPhone 16 (393/2)
|
|
47
|
+
const centerY = 426; // Center of iPhone 16 (852/2)
|
|
48
|
+
const endX = centerX + scrollX;
|
|
49
|
+
const endY = centerY - scrollY; // Invert Y for natural scrolling
|
|
50
|
+
await appium.scroll(session.sessionId, centerX, centerY, endX, endY);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case "drag": {
|
|
54
|
+
const { path } = action;
|
|
55
|
+
if (path && path.length >= 2) {
|
|
56
|
+
const dpr = getDevicePixelRatio();
|
|
57
|
+
const start = path[0];
|
|
58
|
+
const end = path[path.length - 1];
|
|
59
|
+
// Convert to pixels then to logical points
|
|
60
|
+
const startX = Math.round((start.x / scale) / dpr);
|
|
61
|
+
const startY = Math.round((start.y / scale) / dpr);
|
|
62
|
+
const endX = Math.round((end.x / scale) / dpr);
|
|
63
|
+
const endY = Math.round((end.y / scale) / dpr);
|
|
64
|
+
addOutput({
|
|
65
|
+
type: "action",
|
|
66
|
+
text: `Dragging from (${startX}, ${startY}) to (${endX}, ${endY}) points`,
|
|
67
|
+
});
|
|
68
|
+
await appium.drag(session.sessionId, startX, startY, endX, endY);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
addOutput({ type: "info", text: `Drag action missing valid path: ${JSON.stringify(action)}` });
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "keypress": {
|
|
76
|
+
const { keys } = action;
|
|
77
|
+
for (const key of keys) {
|
|
78
|
+
const upperKey = key.toUpperCase();
|
|
79
|
+
if (upperKey === "ESC" || upperKey === "ESCAPE") {
|
|
80
|
+
// Map ESC to home button on iOS
|
|
81
|
+
addOutput({ type: "action", text: "Pressing Home button" });
|
|
82
|
+
await appium.pressButton(session.sessionId, "home");
|
|
83
|
+
}
|
|
84
|
+
else if (upperKey === "ENTER" || upperKey === "RETURN") {
|
|
85
|
+
addOutput({ type: "action", text: "Pressing Return key" });
|
|
86
|
+
await appium.type(session.sessionId, "\n");
|
|
87
|
+
}
|
|
88
|
+
else if (upperKey === "BACKSPACE" || upperKey === "DELETE") {
|
|
89
|
+
addOutput({ type: "action", text: "Pressing Delete key" });
|
|
90
|
+
await appium.type(session.sessionId, "\b");
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
addOutput({ type: "action", text: `Pressing key: ${key}` });
|
|
94
|
+
await appium.type(session.sessionId, key);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "wait": {
|
|
100
|
+
addOutput({ type: "action", text: "Waiting..." });
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
default:
|
|
105
|
+
addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
logger.error("iOS action execution error", {
|
|
110
|
+
action,
|
|
111
|
+
message: error.message,
|
|
112
|
+
stack: error.stack,
|
|
113
|
+
});
|
|
114
|
+
addOutput({ type: "error", text: `Error executing action: ${error.message}` });
|
|
115
|
+
addOutput({ type: "info", text: "Full error details have been logged to the debug log." });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appium WebDriver Client
|
|
3
|
+
*
|
|
4
|
+
* Low-level HTTP client for Appium/WebDriver protocol.
|
|
5
|
+
* Implements W3C WebDriver and Appium-specific endpoints.
|
|
6
|
+
*/
|
|
7
|
+
import { getAppiumUrl } from "./appium-server.js";
|
|
8
|
+
/**
|
|
9
|
+
* Make a request to the Appium server
|
|
10
|
+
* @param {string} method - HTTP method
|
|
11
|
+
* @param {string} path - API path
|
|
12
|
+
* @param {object} body - Request body (optional)
|
|
13
|
+
* @returns {Promise<object>}
|
|
14
|
+
*/
|
|
15
|
+
async function appiumRequest(method, path, body = null) {
|
|
16
|
+
const url = `${getAppiumUrl()}${path}`;
|
|
17
|
+
const options = {
|
|
18
|
+
method,
|
|
19
|
+
headers: {
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
if (body) {
|
|
24
|
+
options.body = JSON.stringify(body);
|
|
25
|
+
}
|
|
26
|
+
const response = await fetch(url, options);
|
|
27
|
+
const data = await response.json();
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const errorMessage = data.value?.message || data.message || JSON.stringify(data);
|
|
30
|
+
throw new Error(`Appium request failed: ${errorMessage}`);
|
|
31
|
+
}
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create a new Appium session
|
|
36
|
+
* @param {object} capabilities - Desired capabilities
|
|
37
|
+
* @returns {Promise<{sessionId: string, value: object}>}
|
|
38
|
+
*/
|
|
39
|
+
export async function createSession(capabilities) {
|
|
40
|
+
const response = await appiumRequest("POST", "/session", {
|
|
41
|
+
capabilities: {
|
|
42
|
+
alwaysMatch: capabilities,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
sessionId: response.value.sessionId,
|
|
47
|
+
value: response.value,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Delete an Appium session
|
|
52
|
+
* @param {string} sessionId
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
*/
|
|
55
|
+
export async function deleteSession(sessionId) {
|
|
56
|
+
await appiumRequest("DELETE", `/session/${sessionId}`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Get a screenshot
|
|
60
|
+
* @param {string} sessionId
|
|
61
|
+
* @returns {Promise<string>} Base64-encoded PNG
|
|
62
|
+
*/
|
|
63
|
+
export async function getScreenshot(sessionId) {
|
|
64
|
+
const response = await appiumRequest("GET", `/session/${sessionId}/screenshot`);
|
|
65
|
+
return response.value;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Get window size
|
|
69
|
+
* @param {string} sessionId
|
|
70
|
+
* @returns {Promise<{width: number, height: number}>}
|
|
71
|
+
*/
|
|
72
|
+
export async function getWindowSize(sessionId) {
|
|
73
|
+
const response = await appiumRequest("GET", `/session/${sessionId}/window/rect`);
|
|
74
|
+
return {
|
|
75
|
+
width: response.value.width,
|
|
76
|
+
height: response.value.height,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Perform a tap action using W3C Actions
|
|
81
|
+
* @param {string} sessionId
|
|
82
|
+
* @param {number} x
|
|
83
|
+
* @param {number} y
|
|
84
|
+
* @returns {Promise<void>}
|
|
85
|
+
*/
|
|
86
|
+
export async function tap(sessionId, x, y) {
|
|
87
|
+
await appiumRequest("POST", `/session/${sessionId}/actions`, {
|
|
88
|
+
actions: [
|
|
89
|
+
{
|
|
90
|
+
type: "pointer",
|
|
91
|
+
id: "finger1",
|
|
92
|
+
parameters: { pointerType: "touch" },
|
|
93
|
+
actions: [
|
|
94
|
+
{ type: "pointerMove", duration: 0, x: Math.round(x), y: Math.round(y) },
|
|
95
|
+
{ type: "pointerDown", button: 0 },
|
|
96
|
+
{ type: "pause", duration: 100 },
|
|
97
|
+
{ type: "pointerUp", button: 0 },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Type text
|
|
105
|
+
* @param {string} sessionId
|
|
106
|
+
* @param {string} text
|
|
107
|
+
* @returns {Promise<void>}
|
|
108
|
+
*/
|
|
109
|
+
export async function type(sessionId, text) {
|
|
110
|
+
// Use W3C key actions
|
|
111
|
+
const keyActions = [];
|
|
112
|
+
for (const char of text) {
|
|
113
|
+
keyActions.push({ type: "keyDown", value: char });
|
|
114
|
+
keyActions.push({ type: "keyUp", value: char });
|
|
115
|
+
}
|
|
116
|
+
await appiumRequest("POST", `/session/${sessionId}/actions`, {
|
|
117
|
+
actions: [
|
|
118
|
+
{
|
|
119
|
+
type: "key",
|
|
120
|
+
id: "keyboard",
|
|
121
|
+
actions: keyActions,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Perform a scroll action using W3C Actions
|
|
128
|
+
* @param {string} sessionId
|
|
129
|
+
* @param {number} startX
|
|
130
|
+
* @param {number} startY
|
|
131
|
+
* @param {number} endX
|
|
132
|
+
* @param {number} endY
|
|
133
|
+
* @param {number} duration - Duration in ms
|
|
134
|
+
* @returns {Promise<void>}
|
|
135
|
+
*/
|
|
136
|
+
export async function scroll(sessionId, startX, startY, endX, endY, duration = 500) {
|
|
137
|
+
await appiumRequest("POST", `/session/${sessionId}/actions`, {
|
|
138
|
+
actions: [
|
|
139
|
+
{
|
|
140
|
+
type: "pointer",
|
|
141
|
+
id: "finger1",
|
|
142
|
+
parameters: { pointerType: "touch" },
|
|
143
|
+
actions: [
|
|
144
|
+
{ type: "pointerMove", duration: 0, x: Math.round(startX), y: Math.round(startY) },
|
|
145
|
+
{ type: "pointerDown", button: 0 },
|
|
146
|
+
{ type: "pointerMove", duration, x: Math.round(endX), y: Math.round(endY) },
|
|
147
|
+
{ type: "pointerUp", button: 0 },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Perform a drag action using W3C Actions
|
|
155
|
+
* @param {string} sessionId
|
|
156
|
+
* @param {number} startX
|
|
157
|
+
* @param {number} startY
|
|
158
|
+
* @param {number} endX
|
|
159
|
+
* @param {number} endY
|
|
160
|
+
* @param {number} duration - Duration in ms
|
|
161
|
+
* @returns {Promise<void>}
|
|
162
|
+
*/
|
|
163
|
+
export async function drag(sessionId, startX, startY, endX, endY, duration = 500) {
|
|
164
|
+
// Drag is similar to scroll but with longer press at start
|
|
165
|
+
await appiumRequest("POST", `/session/${sessionId}/actions`, {
|
|
166
|
+
actions: [
|
|
167
|
+
{
|
|
168
|
+
type: "pointer",
|
|
169
|
+
id: "finger1",
|
|
170
|
+
parameters: { pointerType: "touch" },
|
|
171
|
+
actions: [
|
|
172
|
+
{ type: "pointerMove", duration: 0, x: Math.round(startX), y: Math.round(startY) },
|
|
173
|
+
{ type: "pointerDown", button: 0 },
|
|
174
|
+
{ type: "pause", duration: 200 }, // Hold before drag
|
|
175
|
+
{ type: "pointerMove", duration, x: Math.round(endX), y: Math.round(endY) },
|
|
176
|
+
{ type: "pointerUp", button: 0 },
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Press a device button (home, volumeUp, volumeDown)
|
|
184
|
+
* @param {string} sessionId
|
|
185
|
+
* @param {string} buttonName - 'home', 'volumeUp', or 'volumeDown'
|
|
186
|
+
* @returns {Promise<void>}
|
|
187
|
+
*/
|
|
188
|
+
export async function pressButton(sessionId, buttonName) {
|
|
189
|
+
await appiumRequest("POST", `/session/${sessionId}/execute/sync`, {
|
|
190
|
+
script: "mobile: pressButton",
|
|
191
|
+
args: [{ name: buttonName }],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Get session status
|
|
196
|
+
* @param {string} sessionId
|
|
197
|
+
* @returns {Promise<object>}
|
|
198
|
+
*/
|
|
199
|
+
export async function getSessionStatus(sessionId) {
|
|
200
|
+
try {
|
|
201
|
+
const response = await appiumRequest("GET", `/session/${sessionId}`);
|
|
202
|
+
return response.value;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appium Server Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages Appium server lifecycle for iOS Simulator automation.
|
|
5
|
+
* Auto-starts if not running, cleans up on process exit.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
const APPIUM_URL = process.env.APPIUM_URL || "http://localhost:4723";
|
|
9
|
+
let appiumProcess = null;
|
|
10
|
+
let cleanupRegistered = false;
|
|
11
|
+
/**
|
|
12
|
+
* Check if Appium server is running
|
|
13
|
+
* @returns {Promise<boolean>}
|
|
14
|
+
*/
|
|
15
|
+
export async function isAppiumRunning() {
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(`${APPIUM_URL}/status`);
|
|
18
|
+
return response.ok;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Wait for Appium to be ready
|
|
26
|
+
* @param {number} timeoutMs - Maximum time to wait
|
|
27
|
+
* @returns {Promise<boolean>}
|
|
28
|
+
*/
|
|
29
|
+
async function waitForAppiumReady(timeoutMs = 30000) {
|
|
30
|
+
const deadline = Date.now() + timeoutMs;
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
if (await isAppiumRunning()) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Start Appium server if not already running
|
|
41
|
+
* @returns {Promise<void>}
|
|
42
|
+
*/
|
|
43
|
+
export async function startAppium() {
|
|
44
|
+
if (await isAppiumRunning()) {
|
|
45
|
+
console.log("Appium server is already running");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
console.log("Starting Appium server...");
|
|
49
|
+
appiumProcess = spawn("appium", [], {
|
|
50
|
+
detached: true,
|
|
51
|
+
stdio: "ignore"
|
|
52
|
+
});
|
|
53
|
+
appiumProcess.unref();
|
|
54
|
+
const ready = await waitForAppiumReady(30000);
|
|
55
|
+
if (!ready) {
|
|
56
|
+
throw new Error("Appium server failed to start within 30 seconds");
|
|
57
|
+
}
|
|
58
|
+
console.log("Appium server is ready");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Stop the Appium server if we started it
|
|
62
|
+
*/
|
|
63
|
+
export function stopAppium() {
|
|
64
|
+
if (appiumProcess) {
|
|
65
|
+
try {
|
|
66
|
+
// Kill the process group
|
|
67
|
+
process.kill(-appiumProcess.pid, "SIGTERM");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Process may already be dead
|
|
71
|
+
}
|
|
72
|
+
appiumProcess = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Register cleanup handlers for process exit
|
|
77
|
+
*/
|
|
78
|
+
export function setupAppiumCleanup() {
|
|
79
|
+
if (cleanupRegistered)
|
|
80
|
+
return;
|
|
81
|
+
cleanupRegistered = true;
|
|
82
|
+
const cleanup = () => {
|
|
83
|
+
stopAppium();
|
|
84
|
+
};
|
|
85
|
+
process.on("exit", cleanup);
|
|
86
|
+
process.on("SIGINT", () => {
|
|
87
|
+
cleanup();
|
|
88
|
+
process.exit(0);
|
|
89
|
+
});
|
|
90
|
+
process.on("SIGTERM", () => {
|
|
91
|
+
cleanup();
|
|
92
|
+
process.exit(0);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Get the Appium server URL
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function getAppiumUrl() {
|
|
100
|
+
return APPIUM_URL;
|
|
101
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS Simulator Connection Module
|
|
3
|
+
*
|
|
4
|
+
* Manages connection to iOS Simulator via Appium.
|
|
5
|
+
*/
|
|
6
|
+
import { exec } from "child_process";
|
|
7
|
+
import { promisify } from "util";
|
|
8
|
+
import sharp from "sharp";
|
|
9
|
+
import { startAppium, setupAppiumCleanup } from "./appium-server.js";
|
|
10
|
+
import * as appium from "./appium-client.js";
|
|
11
|
+
import { logger } from "../../utils/logger.js";
|
|
12
|
+
const execAsync = promisify(exec);
|
|
13
|
+
// Active session state
|
|
14
|
+
let activeSession = null;
|
|
15
|
+
/**
|
|
16
|
+
* Get simulator UDID by name (prefers already-booted simulator)
|
|
17
|
+
* @param {string} simulatorName
|
|
18
|
+
* @returns {Promise<string|null>}
|
|
19
|
+
*/
|
|
20
|
+
async function getSimulatorUDID(simulatorName) {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await execAsync("xcrun simctl list devices --json");
|
|
23
|
+
const data = JSON.parse(stdout);
|
|
24
|
+
let firstMatch = null;
|
|
25
|
+
let bootedMatch = null;
|
|
26
|
+
for (const runtime of Object.values(data.devices)) {
|
|
27
|
+
for (const device of runtime) {
|
|
28
|
+
if (device.name === simulatorName && device.isAvailable) {
|
|
29
|
+
if (!firstMatch) {
|
|
30
|
+
firstMatch = device.udid;
|
|
31
|
+
}
|
|
32
|
+
// Prefer the already-booted one
|
|
33
|
+
if (device.state === "Booted") {
|
|
34
|
+
bootedMatch = device.udid;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (bootedMatch) {
|
|
40
|
+
logger.debug(`Found booted simulator: ${bootedMatch}`);
|
|
41
|
+
return bootedMatch;
|
|
42
|
+
}
|
|
43
|
+
return firstMatch;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
logger.error("Failed to get simulator UDID", { error: error.message });
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Find any available simulator (prefers booted, then first available)
|
|
52
|
+
* @returns {Promise<{name: string, udid: string}|null>}
|
|
53
|
+
*/
|
|
54
|
+
async function findAnySimulator() {
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await execAsync("xcrun simctl list devices --json");
|
|
57
|
+
const data = JSON.parse(stdout);
|
|
58
|
+
let firstAvailable = null;
|
|
59
|
+
let bootedDevice = null;
|
|
60
|
+
for (const runtime of Object.values(data.devices)) {
|
|
61
|
+
for (const device of runtime) {
|
|
62
|
+
if (device.isAvailable) {
|
|
63
|
+
if (!firstAvailable) {
|
|
64
|
+
firstAvailable = { name: device.name, udid: device.udid };
|
|
65
|
+
}
|
|
66
|
+
if (device.state === "Booted") {
|
|
67
|
+
bootedDevice = { name: device.name, udid: device.udid };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return bootedDevice || firstAvailable;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
logger.error("Failed to find simulators", { error: error.message });
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Boot simulator if not already booted
|
|
81
|
+
* @param {string} simulatorName
|
|
82
|
+
* @returns {Promise<string>} UDID of the simulator
|
|
83
|
+
*/
|
|
84
|
+
async function bootSimulator(simulatorName) {
|
|
85
|
+
const udid = await getSimulatorUDID(simulatorName);
|
|
86
|
+
if (!udid) {
|
|
87
|
+
throw new Error(`Simulator "${simulatorName}" not found. Run "xcrun simctl list devices" to see available simulators.`);
|
|
88
|
+
}
|
|
89
|
+
console.log(`Found simulator "${simulatorName}" with UDID: ${udid}`);
|
|
90
|
+
// Check if already booted
|
|
91
|
+
const { stdout } = await execAsync("xcrun simctl list devices --json");
|
|
92
|
+
const data = JSON.parse(stdout);
|
|
93
|
+
for (const runtime of Object.values(data.devices)) {
|
|
94
|
+
for (const device of runtime) {
|
|
95
|
+
if (device.udid === udid) {
|
|
96
|
+
if (device.state === "Booted") {
|
|
97
|
+
console.log(`Simulator "${simulatorName}" is already booted`);
|
|
98
|
+
return udid;
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
console.log(`Booting simulator "${simulatorName}"...`);
|
|
105
|
+
await execAsync(`xcrun simctl boot ${udid}`);
|
|
106
|
+
// Open Simulator app to show the device
|
|
107
|
+
await execAsync("open -a Simulator");
|
|
108
|
+
// Wait for boot to complete
|
|
109
|
+
await waitForSimulatorBoot(udid);
|
|
110
|
+
console.log(`Simulator "${simulatorName}" is fully booted`);
|
|
111
|
+
return udid;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Wait for simulator to finish booting
|
|
115
|
+
* @param {string} udid
|
|
116
|
+
* @param {number} timeoutMs
|
|
117
|
+
*/
|
|
118
|
+
async function waitForSimulatorBoot(udid, timeoutMs = 60000) {
|
|
119
|
+
const deadline = Date.now() + timeoutMs;
|
|
120
|
+
while (Date.now() < deadline) {
|
|
121
|
+
try {
|
|
122
|
+
const { stdout } = await execAsync("xcrun simctl list devices --json");
|
|
123
|
+
const data = JSON.parse(stdout);
|
|
124
|
+
for (const runtime of Object.values(data.devices)) {
|
|
125
|
+
for (const device of runtime) {
|
|
126
|
+
if (device.udid === udid && device.state === "Booted") {
|
|
127
|
+
// Additional check: wait for springboard
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
136
|
+
}
|
|
137
|
+
throw new Error("Simulator did not boot in time");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Connect to iOS Simulator
|
|
141
|
+
* @param {string} simulatorName - Name of the simulator (e.g., "iPhone 16"), or null to auto-detect
|
|
142
|
+
* @returns {Promise<string>} Simulator ID for use with other functions
|
|
143
|
+
*/
|
|
144
|
+
export async function connectToDevice(simulatorName) {
|
|
145
|
+
// Setup cleanup handlers
|
|
146
|
+
setupAppiumCleanup();
|
|
147
|
+
// Start Appium if not running
|
|
148
|
+
await startAppium();
|
|
149
|
+
// If no simulator specified, auto-detect
|
|
150
|
+
if (!simulatorName) {
|
|
151
|
+
const found = await findAnySimulator();
|
|
152
|
+
if (!found) {
|
|
153
|
+
console.error("No iOS Simulators found. Create one with Xcode or run:");
|
|
154
|
+
console.error(" xcrun simctl create 'iPhone 16' 'com.apple.CoreSimulator.SimDeviceType.iPhone-16'");
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
simulatorName = found.name;
|
|
158
|
+
console.log(`No simulator specified, using: ${simulatorName}`);
|
|
159
|
+
}
|
|
160
|
+
// Boot simulator
|
|
161
|
+
const udid = await bootSimulator(simulatorName);
|
|
162
|
+
// Create Appium session
|
|
163
|
+
console.log("Creating Appium session...");
|
|
164
|
+
const session = await appium.createSession({
|
|
165
|
+
platformName: "iOS",
|
|
166
|
+
"appium:automationName": "XCUITest",
|
|
167
|
+
"appium:deviceName": simulatorName,
|
|
168
|
+
"appium:udid": udid,
|
|
169
|
+
"appium:noReset": true,
|
|
170
|
+
"appium:shouldTerminateApp": false,
|
|
171
|
+
});
|
|
172
|
+
activeSession = {
|
|
173
|
+
sessionId: session.sessionId,
|
|
174
|
+
udid,
|
|
175
|
+
simulatorName,
|
|
176
|
+
};
|
|
177
|
+
console.log(`Connected to simulator "${simulatorName}" (${udid})`);
|
|
178
|
+
return udid;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Get device info (screen dimensions and scale)
|
|
182
|
+
* @param {string} simulatorId - The UDID returned from connectToDevice
|
|
183
|
+
* @returns {Promise<object>}
|
|
184
|
+
*/
|
|
185
|
+
export async function getDeviceInfo(simulatorId) {
|
|
186
|
+
await ensureSessionAlive();
|
|
187
|
+
// Get logical window size (points)
|
|
188
|
+
const windowSize = await appium.getWindowSize(activeSession.sessionId);
|
|
189
|
+
// Take a test screenshot to get actual pixel dimensions
|
|
190
|
+
// iOS screenshots are at Retina resolution (2x or 3x)
|
|
191
|
+
const testScreenshot = await appium.getScreenshot(activeSession.sessionId);
|
|
192
|
+
const testBuffer = Buffer.from(testScreenshot, "base64");
|
|
193
|
+
const metadata = await sharp(testBuffer).metadata();
|
|
194
|
+
const pixelWidth = metadata.width;
|
|
195
|
+
const pixelHeight = metadata.height;
|
|
196
|
+
// Calculate the iOS device scale factor (typically 2 or 3)
|
|
197
|
+
const devicePixelRatio = Math.round(pixelWidth / windowSize.width);
|
|
198
|
+
console.log(`Device pixel ratio: ${devicePixelRatio}x (${pixelWidth}x${pixelHeight} pixels, ${windowSize.width}x${windowSize.height} points)`);
|
|
199
|
+
// Store the pixel ratio for coordinate conversion
|
|
200
|
+
activeSession.devicePixelRatio = devicePixelRatio;
|
|
201
|
+
// Use pixel dimensions as the "real" resolution (like Android does)
|
|
202
|
+
const targetWidth = 400;
|
|
203
|
+
const scale = pixelWidth > targetWidth ? targetWidth / pixelWidth : 1.0;
|
|
204
|
+
const scaledWidth = Math.round(pixelWidth * scale);
|
|
205
|
+
const scaledHeight = Math.round(pixelHeight * scale);
|
|
206
|
+
return {
|
|
207
|
+
device_width: pixelWidth,
|
|
208
|
+
device_height: pixelHeight,
|
|
209
|
+
scaled_width: scaledWidth,
|
|
210
|
+
scaled_height: scaledHeight,
|
|
211
|
+
scale,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Get screenshot as base64
|
|
216
|
+
* @param {string} simulatorId
|
|
217
|
+
* @param {object} deviceInfo
|
|
218
|
+
* @returns {Promise<string>}
|
|
219
|
+
*/
|
|
220
|
+
export async function getScreenshotAsBase64(simulatorId, deviceInfo) {
|
|
221
|
+
await ensureSessionAlive();
|
|
222
|
+
const base64 = await appium.getScreenshot(activeSession.sessionId);
|
|
223
|
+
let buffer = Buffer.from(base64, "base64");
|
|
224
|
+
logger.debug(`iOS screenshot captured: ${buffer.length} bytes before scaling`);
|
|
225
|
+
if (deviceInfo.scale < 1.0) {
|
|
226
|
+
buffer = await sharp(buffer)
|
|
227
|
+
.resize({ width: deviceInfo.scaled_width, height: deviceInfo.scaled_height })
|
|
228
|
+
.png()
|
|
229
|
+
.toBuffer();
|
|
230
|
+
logger.debug(`iOS screenshot scaled: ${buffer.length} bytes after scaling`);
|
|
231
|
+
}
|
|
232
|
+
return buffer.toString("base64");
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Ensure the Appium session is still alive, recreate if dead
|
|
236
|
+
*/
|
|
237
|
+
async function ensureSessionAlive() {
|
|
238
|
+
if (!activeSession) {
|
|
239
|
+
throw new Error("No active iOS session. Call connectToDevice first.");
|
|
240
|
+
}
|
|
241
|
+
const status = await appium.getSessionStatus(activeSession.sessionId);
|
|
242
|
+
if (!status) {
|
|
243
|
+
console.log("Session died, recreating...");
|
|
244
|
+
const session = await appium.createSession({
|
|
245
|
+
platformName: "iOS",
|
|
246
|
+
"appium:automationName": "XCUITest",
|
|
247
|
+
"appium:deviceName": activeSession.simulatorName,
|
|
248
|
+
"appium:udid": activeSession.udid,
|
|
249
|
+
"appium:noReset": true,
|
|
250
|
+
"appium:shouldTerminateApp": false,
|
|
251
|
+
});
|
|
252
|
+
activeSession.sessionId = session.sessionId;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Get the active session (for use by actions module)
|
|
257
|
+
* @returns {object|null}
|
|
258
|
+
*/
|
|
259
|
+
export function getActiveSession() {
|
|
260
|
+
return activeSession;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Get the device pixel ratio (for coordinate conversion)
|
|
264
|
+
* @returns {number}
|
|
265
|
+
*/
|
|
266
|
+
export function getDevicePixelRatio() {
|
|
267
|
+
return activeSession?.devicePixelRatio || 3;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Disconnect and cleanup
|
|
271
|
+
*/
|
|
272
|
+
export async function disconnect() {
|
|
273
|
+
if (activeSession) {
|
|
274
|
+
try {
|
|
275
|
+
await appium.deleteSession(activeSession.sessionId);
|
|
276
|
+
}
|
|
277
|
+
catch { }
|
|
278
|
+
activeSession = null;
|
|
279
|
+
}
|
|
280
|
+
}
|