@loadmill/droid-cua 2.43.0 → 2.44.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 (30) hide show
  1. package/README.md +114 -7
  2. package/build/index.js +39 -15
  3. package/build/src/cli/device-selector.js +13 -6
  4. package/build/src/cli/headless-target.js +24 -0
  5. package/build/src/cli/ios-device-command.js +121 -0
  6. package/build/src/cli/ios-device-list.js +32 -0
  7. package/build/src/cli/network-capture-device.js +21 -0
  8. package/build/src/commands/help.js +14 -3
  9. package/build/src/core/execution-engine.js +3 -0
  10. package/build/src/desktop-shared/ios-real-device.js +82 -0
  11. package/build/src/device/factory.js +4 -0
  12. package/build/src/device/ios/actions.js +48 -12
  13. package/build/src/device/ios/appium-server.js +7 -18
  14. package/build/src/device/ios/connection.js +71 -12
  15. package/build/src/device/ios/node-cli-env.js +55 -0
  16. package/build/src/device/ios/real-device/device-state.js +40 -0
  17. package/build/src/device/ios/real-device/devicectl.js +154 -0
  18. package/build/src/device/ios/real-device/readiness.js +439 -0
  19. package/build/src/device/ios/real-device/session.js +176 -0
  20. package/build/src/device/ios/real-device/signing.js +466 -0
  21. package/build/src/device/ios/real-device/wda-provisioning.js +229 -0
  22. package/build/src/network/certificate-authority.js +21 -1
  23. package/build/src/network/certificate-install.js +38 -0
  24. package/build/src/network/local-proxy.js +21 -1
  25. package/build/src/network/run-network-capture.js +16 -5
  26. package/package.json +1 -1
  27. package/skill/droid-cua/SKILL.md +2 -2
  28. package/skill/droid-cua/references/authoring-tests.md +1 -1
  29. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +31 -0
  30. package/skill/droid-cua/references/running-and-healing.md +11 -2
@@ -19,6 +19,7 @@ export async function handleHelp(args, session, context) {
19
19
  addOutput({ type: 'info', text: ' droid-cua --target web --instructions <file> [options]' });
20
20
  addOutput({ type: 'info', text: ' droid-cua --target desktop --instructions <file> [options]' });
21
21
  addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --instructions <file> [options]' });
22
+ addOutput({ type: 'info', text: ' droid-cua ios-device check [--udid <udid>] [--ios-team <TEAMID>] [--fix]' });
22
23
  addOutput({ type: 'info', text: '' });
23
24
  addOutput({ type: 'info', text: 'Options:' });
24
25
  addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default), web, or desktop' });
@@ -27,8 +28,9 @@ export async function handleHelp(args, session, context) {
27
28
  addOutput({ type: 'info', text: ' --desktop-window <id> Select a window within --desktop-app' });
28
29
  addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default); accessibility requires --desktop-app' });
29
30
  addOutput({ type: 'info', text: ' --list-desktop-apps List running desktop apps and windows' });
30
- addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
31
+ addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial, iOS Simulator, or ios-device:<udid|auto>)' });
31
32
  addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
33
+ addOutput({ type: 'info', text: ' --ios-team <TEAMID> Apple team that signs the helper app on a USB iPhone' });
32
34
  addOutput({ type: 'info', text: ' --device-source <src> Mobile source: local (default) or lambdatest' });
33
35
  addOutput({ type: 'info', text: ' --device-name <name> LambdaTest real device name' });
34
36
  addOutput({ type: 'info', text: ' --os-version <version> LambdaTest OS version' });
@@ -55,7 +57,7 @@ export async function handleHelp(args, session, context) {
55
57
  addOutput({ type: 'info', text: ' --record Record screenshots during execution' });
56
58
  addOutput({ type: 'info', text: ' --explain Explain non-obvious decisions and completion evidence' });
57
59
  addOutput({ type: 'info', text: ' --report [path] Save a self-contained HTML report locally' });
58
- addOutput({ type: 'info', text: ' --network-proxy Forward local mobile traffic through 127.0.0.1:8765 without capture' });
60
+ addOutput({ type: 'info', text: ' --network-proxy Forward local mobile traffic through port 8765 without capture (all Mac addresses for iPhones and Android phones)' });
59
61
  addOutput({ type: 'info', text: ' --capture-network Capture local mobile or browser test traffic to one JSONL log per test' });
60
62
  addOutput({ type: 'info', text: ' --no-cloud-report Skip uploading the HTML report and screenshots to Loadmill' });
61
63
  addOutput({ type: 'info', text: ' --report-metadata <path> Write uploaded Loadmill report metadata as JSON' });
@@ -77,7 +79,7 @@ export async function handleHelp(args, session, context) {
77
79
  addOutput({ type: 'info', text: '' });
78
80
  addOutput({ type: 'info', text: 'Platform Support:' });
79
81
  addOutput({ type: 'info', text: ' Android: Uses ADB to communicate with Android devices (physical or emulator)' });
80
- addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulator automation' });
82
+ addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulators and iPhones connected by USB' });
81
83
  addOutput({ type: 'info', text: ' LambdaTest: Uses real cloud devices for headless mobile runs' });
82
84
  addOutput({ type: 'info', text: ' Web: Uses Playwright with installed Google Chrome or Microsoft Edge for browser execution' });
83
85
  addOutput({ type: 'info', text: ' Desktop: Controls the primary display or a selected application on macOS and Windows' });
@@ -94,11 +96,20 @@ export async function handleHelp(args, session, context) {
94
96
  addOutput({ type: 'info', text: ' 3. XCUITest driver: appium driver install xcuitest' });
95
97
  addOutput({ type: 'info', text: ' Note: Appium server is auto-started when iOS platform is detected' });
96
98
  addOutput({ type: 'info', text: '' });
99
+ addOutput({ type: 'info', text: 'iPhone over USB (macOS only):' });
100
+ addOutput({ type: 'info', text: ' 1. Xcode that supports the iPhone\'s iOS, opened once' });
101
+ addOutput({ type: 'info', text: ' 2. Your Apple ID in Xcode, on your company\'s paid Apple Developer team' });
102
+ addOutput({ type: 'info', text: ' (Developer role with Access to Certificates, Identifiers & Profiles)' });
103
+ addOutput({ type: 'info', text: ' 3. On the iPhone: tap Trust, turn on Developer Mode, set Auto-Lock to Never' });
104
+ addOutput({ type: 'info', text: ' 4. droid-cua ios-device check --fix (checks the iPhone and installs the helper app)' });
105
+ addOutput({ type: 'info', text: '' });
97
106
  addOutput({ type: 'info', text: 'Examples:' });
98
107
  addOutput({ type: 'info', text: ' droid-cua --avd adb:emulator-5554 (Connected Android target by adb serial)' });
99
108
  addOutput({ type: 'info', text: ' droid-cua --avd avd:Pixel_8_API_35 (Launch Android AVD then connect)' });
100
109
  addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
101
110
  addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
111
+ addOutput({ type: 'info', text: ' droid-cua run tests/login.dcua --avd ios-device:auto (iPhone connected by USB)' });
112
+ addOutput({ type: 'info', text: ' droid-cua run tests/login.dcua --avd ios-device:<udid> --ios-team ABCDE12345' });
102
113
  addOutput({ type: 'info', text: ' droid-cua run tests/login.dcua admin/refund.dcua --context app/context.md' });
103
114
  addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
104
115
  addOutput({ type: 'info', text: ' droid-cua --instructions tests --test-data tests/test-data.md --scenario "Run every checkout test with each buyer account"' });
@@ -206,6 +206,9 @@ function getInstructionScrollDirection(instruction) {
206
206
  return null;
207
207
  }
208
208
  const normalizedInstruction = instruction.toLowerCase();
209
+ if ((normalizedInstruction.match(/\bscroll/g) || []).length > 1) {
210
+ return null;
211
+ }
209
212
  if (normalizedInstruction.includes("scroll down")) {
210
213
  return 1;
211
214
  }
@@ -0,0 +1,82 @@
1
+ import { iosDeviceLabel, iosDeviceStateHint } from "../cli/ios-device-list.js";
2
+ import { describeFailure } from "../device/ios/real-device/readiness.js";
3
+ import { IOS_DEVICE_PREFIX, parseIosDeviceToken } from "../device/ios/real-device/session.js";
4
+ export const IPHONE_STATUS_EVENT = "ios_device.status";
5
+ const TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/;
6
+ const CAUSE_REMEDIATION = {
7
+ "command-line-tools-selected": [{ kind: "run_fix", label: "Set Xcode Developer Path", fixId: "ios_set_xcode_developer_path" }],
8
+ "xcode-license": [{ kind: "run_fix", label: "Accept Xcode License", fixId: "ios_accept_xcode_license" }],
9
+ "ios-platform-missing": [{ kind: "run_fix", label: "Download iOS Platform", fixId: "ios_download_ios_platform" }],
10
+ };
11
+ const XCODE_CHECK_IDS = ["xcode_devicectl", "ios_platform"];
12
+ export function iphoneDeviceOptions(devices) {
13
+ return devices
14
+ .filter((device) => device.transport === "wired" && device.udid)
15
+ .map((device) => {
16
+ const hint = iosDeviceStateHint(device);
17
+ return {
18
+ name: `${IOS_DEVICE_PREFIX}${device.udid}`,
19
+ label: iosDeviceLabel(device),
20
+ platform: "ios",
21
+ running: true,
22
+ iosKind: "device",
23
+ iosUdid: device.udid,
24
+ ...(hint ? { iosStateHint: hint } : {}),
25
+ };
26
+ })
27
+ .sort((a, b) => a.label.localeCompare(b.label));
28
+ }
29
+ export function isIphoneDeviceName(deviceName) {
30
+ return parseIosDeviceToken(deviceName) !== null;
31
+ }
32
+ export function toSetupCheckResult(result) {
33
+ const remediation = result.remediation ?? CAUSE_REMEDIATION[result.cause];
34
+ return {
35
+ id: result.id,
36
+ status: result.status,
37
+ title: result.title,
38
+ summary: result.summary,
39
+ ...(result.details ? { details: result.details } : {}),
40
+ blocking: result.blocking,
41
+ checkedAt: result.checkedAt,
42
+ ...(remediation ? { remediation } : {}),
43
+ ...(result.kind ? { kind: result.kind } : {}),
44
+ ...(result.blockedBy ? { blockedBy: result.blockedBy } : {}),
45
+ };
46
+ }
47
+ export function orderIphoneSetupChecks(deviceResults, toolResults) {
48
+ const isXcode = (result) => XCODE_CHECK_IDS.includes(result.id);
49
+ return [...deviceResults.filter(isXcode), ...toolResults, ...deviceResults.filter((result) => !isXcode(result))];
50
+ }
51
+ export function iphoneFixResult(checkId, results) {
52
+ const result = results.find((entry) => entry.id === checkId);
53
+ if (result?.status === "pass") {
54
+ return { ok: true, summary: "Fix completed successfully.", details: result.summary };
55
+ }
56
+ return { ok: false, summary: "Fix failed.", details: result ? describeFailure(result) : `Unknown iPhone check: ${checkId}.` };
57
+ }
58
+ export function normalizeTeamId(value) {
59
+ if (typeof value !== "string")
60
+ return null;
61
+ const trimmed = value.trim().toUpperCase();
62
+ return TEAM_ID_PATTERN.test(trimmed) ? trimmed : null;
63
+ }
64
+ export function iphoneTeamOptions(teams) {
65
+ return teams.map((team) => ({
66
+ teamId: team.teamId,
67
+ label: team.teamName ? `${team.teamName} (${team.teamId})` : team.teamId,
68
+ }));
69
+ }
70
+ export function iphoneConnectOptions(deviceName, teamId) {
71
+ if (!isIphoneDeviceName(deviceName))
72
+ return {};
73
+ const normalized = normalizeTeamId(teamId);
74
+ return normalized ? { teamId: normalized } : {};
75
+ }
76
+ export function iphoneStatusLogEvent({ status, message }) {
77
+ return {
78
+ kind: status === "unlocked" ? "success" : "warning",
79
+ text: message,
80
+ payload: { event: IPHONE_STATUS_EVENT, status },
81
+ };
82
+ }
@@ -9,6 +9,7 @@ import * as cloudConnection from "./cloud/connection.js";
9
9
  import * as cloudActions from "./cloud/actions.js";
10
10
  import * as iosConnection from "./ios/connection.js";
11
11
  import * as iosActions from "./ios/actions.js";
12
+ import { parseIosDeviceToken } from "./ios/real-device/session.js";
12
13
  // Current platform state
13
14
  let currentPlatform = null;
14
15
  let currentSource = "local";
@@ -25,6 +26,9 @@ export function detectPlatform(deviceName) {
25
26
  if (process.env.DROID_CUA_PLATFORM === "android") {
26
27
  return "android";
27
28
  }
29
+ if (parseIosDeviceToken(deviceName)) {
30
+ return "ios";
31
+ }
28
32
  // Auto-detect from device name
29
33
  if (deviceName) {
30
34
  const lower = deviceName.toLowerCase();
@@ -7,25 +7,55 @@ import * as appium from "./appium-client.js";
7
7
  import { buildActionEventMeta } from "../action-event-meta.js";
8
8
  import { buildIosAppSwitcherGesture, buildMobileKeypressPayload, getIosTransport, normalizeMobileKeypress } from "../mobile-keypress.js";
9
9
  import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSensitiveDebugFields, buildTypeActionPayload, buildTypeActionText, isSensitiveAction, } from "../sensitive-action.js";
10
- import { getActiveSession, getDevicePixelRatio } from "./connection.js";
10
+ import { getActiveSession, getDevicePixelRatio, recoverRealDevice } from "./connection.js";
11
11
  import { logger } from "../../utils/logger.js";
12
12
  import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
13
13
  import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
14
14
  import { resolveScrollGesture } from "../scroll-gesture.js";
15
- import { closeAppiumKeyboardWithTap } from "../keyboard.js";
15
+ import { KEYBOARD_CLOSE_RESULTS, closeAppiumKeyboardWithTap } from "../keyboard.js";
16
+ export function realDeviceScreenBounds(session) {
17
+ const size = session?.realDevice ? session.windowSize : null;
18
+ if (!Number.isFinite(size?.width) || !Number.isFinite(size?.height)) {
19
+ return {};
20
+ }
21
+ return { minX: 0, maxX: Math.round(size.width) - 1, minY: 0, maxY: Math.round(size.height) - 1 };
22
+ }
23
+ function clampToBounds(value, min, max) {
24
+ return Number.isFinite(min) && Number.isFinite(max) ? Math.min(Math.max(value, min), max) : value;
25
+ }
16
26
  export async function closeKeyboard(_deviceId, { dismissalPoint = null, scale = 1 } = {}) {
17
27
  const session = getActiveSession();
18
28
  if (!session) {
19
29
  throw new Error("No active iOS session");
20
30
  }
21
31
  const dpr = getDevicePixelRatio();
22
- const tap = dismissalPoint
23
- ? () => appium.tap(session.sessionId, Math.round((dismissalPoint.x / scale) / dpr), Math.round((dismissalPoint.y / scale) / dpr))
32
+ const tapPoint = dismissalPoint
33
+ ? { x: Math.round((dismissalPoint.x / scale) / dpr), y: Math.round((dismissalPoint.y / scale) / dpr) }
24
34
  : null;
25
- return closeAppiumKeyboardWithTap({
26
- isKeyboardShown: () => appium.isKeyboardShown(session.sessionId),
27
- tap,
35
+ const keyboardShownReadings = [];
36
+ const result = await closeAppiumKeyboardWithTap({
37
+ isKeyboardShown: async () => {
38
+ const shown = await appium.isKeyboardShown(session.sessionId);
39
+ keyboardShownReadings.push(shown);
40
+ return shown;
41
+ },
42
+ tap: tapPoint ? () => appium.tap(session.sessionId, tapPoint.x, tapPoint.y) : null,
28
43
  });
44
+ if (result.status === KEYBOARD_CLOSE_RESULTS.FAILED) {
45
+ const details = {
46
+ platform: "ios",
47
+ realDevice: session.realDevice === true,
48
+ dismissalPoint,
49
+ tapPoint,
50
+ unit: "points",
51
+ keyboardShownBefore: keyboardShownReadings[0] ?? null,
52
+ keyboardShownAfter: keyboardShownReadings[1] ?? null,
53
+ error: result.error ?? null,
54
+ };
55
+ logger.debug("iOS keyboard close failed", details);
56
+ emitDesktopDebug("device.keyboard.close_failed", "device", {}, details);
57
+ }
58
+ return result;
29
59
  }
30
60
  /**
31
61
  * Handle an action from the CUA model
@@ -100,7 +130,8 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
100
130
  scale,
101
131
  dpr,
102
132
  fallbackStartX: 197,
103
- fallbackStartY: 426
133
+ fallbackStartY: 426,
134
+ ...realDeviceScreenBounds(session)
104
135
  });
105
136
  addOutput({
106
137
  type: "action",
@@ -117,10 +148,11 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
117
148
  const start = path[0];
118
149
  const end = path[path.length - 1];
119
150
  // Convert to pixels then to logical points
120
- const startX = Math.round((start.x / scale) / dpr);
121
- const startY = Math.round((start.y / scale) / dpr);
122
- const endX = Math.round((end.x / scale) / dpr);
123
- const endY = Math.round((end.y / scale) / dpr);
151
+ const { minX, maxX, minY, maxY } = realDeviceScreenBounds(session);
152
+ const startX = clampToBounds(Math.round((start.x / scale) / dpr), minX, maxX);
153
+ const startY = clampToBounds(Math.round((start.y / scale) / dpr), minY, maxY);
154
+ const endX = clampToBounds(Math.round((end.x / scale) / dpr), minX, maxX);
155
+ const endY = clampToBounds(Math.round((end.y / scale) / dpr), minY, maxY);
124
156
  addOutput({
125
157
  type: "action",
126
158
  text: `Dragging from (${startX}, ${startY}) to (${endX}, ${endY}) points`,
@@ -213,6 +245,10 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
213
245
  }
214
246
  });
215
247
  addOutput({ type: "info", text: "Full error details have been logged to the debug log." });
248
+ if (session.realDevice) {
249
+ await recoverRealDevice(error, { log: (text) => addOutput({ type: "info", text }) })
250
+ .catch((recoveryError) => addOutput({ type: "error", text: recoveryError.message }));
251
+ }
216
252
  return { success: false, error: outputError.message };
217
253
  }
218
254
  }
@@ -5,30 +5,19 @@
5
5
  * Auto-starts if not running, cleans up on process exit.
6
6
  */
7
7
  import { spawn } from "child_process";
8
- import { existsSync, readdirSync } from "fs";
8
+ import { existsSync } from "fs";
9
+ import os from "os";
9
10
  import path from "path";
10
- import { COMMON_NODE_BIN_DIRS, makeNodeCliCommandEnv } from "./node-cli-env.js";
11
+ import { COMMON_NODE_BIN_DIRS, listNvmNodeBinDirs, makeNodeCliCommandEnv } from "./node-cli-env.js";
11
12
  let appiumProcess = null;
12
13
  let cleanupRegistered = false;
13
14
  function getAppiumBaseUrl() {
14
15
  return process.env.APPIUM_URL || "http://localhost:4723";
15
16
  }
16
- function collectNvmAppiumCandidates() {
17
- const home = process.env.HOME;
18
- if (!home)
19
- return [];
20
- const nvmNodeVersionsDir = path.join(home, ".nvm", "versions", "node");
21
- if (!existsSync(nvmNodeVersionsDir))
22
- return [];
23
- try {
24
- return readdirSync(nvmNodeVersionsDir)
25
- .map((versionDir) => path.join(nvmNodeVersionsDir, versionDir, "bin", "appium"))
26
- .filter((candidate) => existsSync(candidate))
27
- .reverse();
28
- }
29
- catch {
30
- return [];
31
- }
17
+ export function collectNvmAppiumCandidates(home = os.homedir()) {
18
+ return listNvmNodeBinDirs(home)
19
+ .map((binDir) => path.join(binDir, "appium"))
20
+ .filter((candidate) => existsSync(candidate));
32
21
  }
33
22
  export function resolveAppiumCommand() {
34
23
  const explicit = process.env.DROID_CUA_APPIUM_PATH?.trim() || process.env.APPIUM_PATH?.trim();
@@ -10,6 +10,7 @@ import { startAppium, setupAppiumCleanup } from "./appium-server.js";
10
10
  import * as appium from "./appium-client.js";
11
11
  import { logger } from "../../utils/logger.js";
12
12
  import { emitDesktopDebug } from "../../utils/desktop-debug.js";
13
+ import { IOS_DEVICE_PREFIX, createRealDeviceSession, parseIosDeviceToken, recoverIfLocked, recoverRealDeviceSession, startRealDeviceSession, } from "./real-device/session.js";
13
14
  const execAsync = promisify(exec);
14
15
  // Active session state
15
16
  let activeSession = null;
@@ -157,6 +158,13 @@ export async function connectToDevice(simulatorName, options = {}) {
157
158
  setupAppiumCleanup();
158
159
  // Start Appium if not running
159
160
  await startAppium({ log });
161
+ const realDevice = parseIosDeviceToken(simulatorName);
162
+ if (realDevice) {
163
+ return connectToRealDevice(realDevice.udid, {
164
+ log,
165
+ teamId: resolveRealDeviceTeamId(realDevice.udid, options.teamId),
166
+ });
167
+ }
160
168
  // If no simulator specified, auto-detect
161
169
  if (!simulatorName) {
162
170
  const found = await findAnySimulator();
@@ -194,6 +202,22 @@ export async function connectToDevice(simulatorName, options = {}) {
194
202
  });
195
203
  return simulator.udid;
196
204
  }
205
+ export function resolveRealDeviceTeamId(udid, explicitTeamId = null) {
206
+ const previous = activeSession?.realDevice && activeSession.udid === udid ? activeSession.teamId : null;
207
+ return explicitTeamId || previous || process.env.DROID_CUA_IOS_TEAM_ID || null;
208
+ }
209
+ async function connectToRealDevice(udid, { log, teamId }) {
210
+ activeSession = await startRealDeviceSession({ udid, teamId, log });
211
+ log(`Connected to iPhone "${activeSession.deviceName}" (${activeSession.udid})`);
212
+ emitDesktopDebug("device.connect", "device", {}, {
213
+ platform: "ios",
214
+ stage: "success",
215
+ realDevice: true,
216
+ deviceName: activeSession.deviceName,
217
+ udid: activeSession.udid
218
+ });
219
+ return `${IOS_DEVICE_PREFIX}${activeSession.udid}`;
220
+ }
197
221
  /**
198
222
  * Get device info (screen dimensions and scale)
199
223
  * @param {string} simulatorId - The UDID returned from connectToDevice
@@ -216,15 +240,18 @@ export async function getDeviceInfo(simulatorId, options = {}) {
216
240
  log(`Device pixel ratio: ${devicePixelRatio}x (${pixelWidth}x${pixelHeight} pixels, ${windowSize.width}x${windowSize.height} points)`);
217
241
  // Store the pixel ratio for coordinate conversion
218
242
  activeSession.devicePixelRatio = devicePixelRatio;
243
+ activeSession.windowSize = windowSize;
219
244
  // Use pixel dimensions as the "real" resolution (like Android does)
220
245
  const targetWidth = 400;
221
246
  const scale = pixelWidth > targetWidth ? targetWidth / pixelWidth : 1.0;
222
247
  const scaledWidth = Math.round(pixelWidth * scale);
223
248
  const scaledHeight = Math.round(pixelHeight * scale);
249
+ const name = activeSession.realDevice ? activeSession.deviceName : activeSession.simulatorName;
250
+ const model = activeSession.realDevice ? activeSession.model : activeSession.simulatorName;
224
251
  return {
225
252
  platform: "ios",
226
- device_name: activeSession?.simulatorName || simulatorId,
227
- model: activeSession?.simulatorName || simulatorId,
253
+ device_name: name || simulatorId,
254
+ model: model || simulatorId,
228
255
  device_width: pixelWidth,
229
256
  device_height: pixelHeight,
230
257
  scaled_width: scaledWidth,
@@ -238,9 +265,24 @@ export async function getDeviceInfo(simulatorId, options = {}) {
238
265
  * @param {object} deviceInfo
239
266
  * @returns {Promise<string>}
240
267
  */
241
- export async function getScreenshotAsBase64(simulatorId, deviceInfo) {
268
+ export async function getScreenshotAsBase64(simulatorId, deviceInfo, { deps } = {}) {
242
269
  await ensureSessionAlive();
243
- const rawBase64 = await appium.getScreenshot(activeSession.sessionId);
270
+ if (activeSession.realDevice) {
271
+ const sessionId = await recoverIfLocked(activeSession, { deps });
272
+ if (sessionId) {
273
+ activeSession.sessionId = sessionId;
274
+ }
275
+ }
276
+ let rawBase64;
277
+ try {
278
+ rawBase64 = await appium.getScreenshot(activeSession.sessionId);
279
+ }
280
+ catch (error) {
281
+ if (!await recoverRealDevice(error)) {
282
+ throw error;
283
+ }
284
+ rawBase64 = await appium.getScreenshot(activeSession.sessionId);
285
+ }
244
286
  let buffer = Buffer.from(rawBase64, "base64");
245
287
  logger.debug(`iOS screenshot captured: ${buffer.length} bytes before scaling`);
246
288
  if (deviceInfo.scale < 1.0) {
@@ -279,14 +321,20 @@ async function ensureSessionAlive() {
279
321
  reason: "appium session no longer active"
280
322
  });
281
323
  console.log("Session died, recreating...");
282
- const session = await appium.createSession({
283
- platformName: "iOS",
284
- "appium:automationName": "XCUITest",
285
- "appium:deviceName": activeSession.simulatorName,
286
- "appium:udid": activeSession.udid,
287
- "appium:noReset": true,
288
- "appium:shouldTerminateApp": false,
289
- });
324
+ const session = activeSession.realDevice
325
+ ? await createRealDeviceSession({
326
+ udid: activeSession.udid,
327
+ bundleIdBase: activeSession.bundleIdBase,
328
+ deviceName: activeSession.deviceName,
329
+ })
330
+ : await appium.createSession({
331
+ platformName: "iOS",
332
+ "appium:automationName": "XCUITest",
333
+ "appium:deviceName": activeSession.simulatorName,
334
+ "appium:udid": activeSession.udid,
335
+ "appium:noReset": true,
336
+ "appium:shouldTerminateApp": false,
337
+ });
290
338
  activeSession.sessionId = session.sessionId;
291
339
  emitDesktopDebug("reconnect.attempt", "device", {}, {
292
340
  platform: "ios",
@@ -295,6 +343,17 @@ async function ensureSessionAlive() {
295
343
  });
296
344
  }
297
345
  }
346
+ export async function recoverRealDevice(error, { log = console.log, deps } = {}) {
347
+ if (!activeSession?.realDevice) {
348
+ return false;
349
+ }
350
+ const sessionId = await recoverRealDeviceSession(activeSession, error, { log, deps });
351
+ if (!sessionId) {
352
+ return false;
353
+ }
354
+ activeSession.sessionId = sessionId;
355
+ return true;
356
+ }
298
357
  /**
299
358
  * Get the active session (for use by actions module)
300
359
  * @returns {object|null}
@@ -1,3 +1,5 @@
1
+ import { accessSync, constants as fsConstants, readdirSync } from "fs";
2
+ import os from "os";
1
3
  import path from "path";
2
4
  export const COMMON_NODE_BIN_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin"];
3
5
  function prependPathEntries(entries, originalPath) {
@@ -26,3 +28,56 @@ export function makeNodeCliCommandEnv(command) {
26
28
  PATH: prependPathEntries(binDirs, process.env.PATH)
27
29
  };
28
30
  }
31
+ function parseNodeVersion(name) {
32
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(name);
33
+ return match ? match.slice(1).map(Number) : null;
34
+ }
35
+ function compareVersionsDescending(a, b) {
36
+ for (let i = 0; i < 3; i += 1) {
37
+ if (a[i] !== b[i])
38
+ return b[i] - a[i];
39
+ }
40
+ return 0;
41
+ }
42
+ function isExecutable(filePath) {
43
+ try {
44
+ accessSync(filePath, fsConstants.X_OK);
45
+ return true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ export function listNvmNodeBinDirs(home = os.homedir()) {
52
+ if (!home)
53
+ return [];
54
+ const versionsDir = path.join(home, ".nvm", "versions", "node");
55
+ let names;
56
+ try {
57
+ names = readdirSync(versionsDir);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ return names
63
+ .map((name) => ({ name, version: parseNodeVersion(name) }))
64
+ .filter((entry) => entry.version)
65
+ .sort((a, b) => compareVersionsDescending(a.version, b.version))
66
+ .map((entry) => path.join(versionsDir, entry.name, "bin"));
67
+ }
68
+ export function resolveNodeCliCommand(command, { pathEnv = process.env.PATH, commonDirs = COMMON_NODE_BIN_DIRS, home = os.homedir() } = {}) {
69
+ const pathDirs = pathEnv
70
+ ?.split(path.delimiter)
71
+ .map((dir) => dir.trim())
72
+ .filter(Boolean) ?? [];
73
+ for (const dir of new Set([...pathDirs, ...commonDirs])) {
74
+ const candidate = path.join(dir, command);
75
+ if (isExecutable(candidate))
76
+ return candidate;
77
+ }
78
+ const nvmBinDir = listNvmNodeBinDirs(home).find((dir) => isExecutable(path.join(dir, "node")));
79
+ if (nvmBinDir && isExecutable(path.join(nvmBinDir, command))) {
80
+ return path.join(nvmBinDir, command);
81
+ }
82
+ return command;
83
+ }
@@ -0,0 +1,40 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const DEFAULT_DIR = path.join(os.homedir(), ".droid-cua", "ios-devices");
5
+ function statePath(dir) {
6
+ return path.join(dir, "state.json");
7
+ }
8
+ async function readAll(dir) {
9
+ try {
10
+ return JSON.parse(await readFile(statePath(dir), "utf8")) || {};
11
+ }
12
+ catch {
13
+ return {};
14
+ }
15
+ }
16
+ function isPlainObject(value) {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+ export async function readDeviceState(udid, { dir = DEFAULT_DIR } = {}) {
20
+ const all = await readAll(dir);
21
+ return all[udid] ?? null;
22
+ }
23
+ export async function updateDeviceState(udid, patch, { dir = DEFAULT_DIR } = {}) {
24
+ const all = await readAll(dir);
25
+ const current = all[udid] ?? {};
26
+ const next = { ...current };
27
+ for (const [key, value] of Object.entries(patch)) {
28
+ next[key] = isPlainObject(value) && isPlainObject(current[key]) ? { ...current[key], ...value } : value;
29
+ }
30
+ all[udid] = next;
31
+ await mkdir(dir, { recursive: true });
32
+ const tempPath = `${statePath(dir)}.${process.pid}.${Date.now()}.tmp`;
33
+ await writeFile(tempPath, `${JSON.stringify(all, null, 2)}\n`, "utf8");
34
+ await rename(tempPath, statePath(dir));
35
+ return next;
36
+ }
37
+ export async function didIosVersionChange(udid, currentVersion, { dir = DEFAULT_DIR } = {}) {
38
+ const state = await readDeviceState(udid, { dir });
39
+ return Boolean(state?.lastIosVersion) && state.lastIosVersion !== currentVersion;
40
+ }