@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.
@@ -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
+ }