@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.
- package/README.md +114 -7
- package/build/index.js +39 -15
- package/build/src/cli/device-selector.js +13 -6
- package/build/src/cli/headless-target.js +24 -0
- package/build/src/cli/ios-device-command.js +121 -0
- package/build/src/cli/ios-device-list.js +32 -0
- package/build/src/cli/network-capture-device.js +21 -0
- package/build/src/commands/help.js +14 -3
- package/build/src/core/execution-engine.js +3 -0
- package/build/src/desktop-shared/ios-real-device.js +82 -0
- package/build/src/device/factory.js +4 -0
- package/build/src/device/ios/actions.js +48 -12
- package/build/src/device/ios/appium-server.js +7 -18
- package/build/src/device/ios/connection.js +71 -12
- package/build/src/device/ios/node-cli-env.js +55 -0
- package/build/src/device/ios/real-device/device-state.js +40 -0
- package/build/src/device/ios/real-device/devicectl.js +154 -0
- package/build/src/device/ios/real-device/readiness.js +439 -0
- package/build/src/device/ios/real-device/session.js +176 -0
- package/build/src/device/ios/real-device/signing.js +466 -0
- package/build/src/device/ios/real-device/wda-provisioning.js +229 -0
- package/build/src/network/certificate-authority.js +21 -1
- package/build/src/network/certificate-install.js +38 -0
- package/build/src/network/local-proxy.js +21 -1
- package/build/src/network/run-network-capture.js +16 -5
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +2 -2
- package/skill/droid-cua/references/authoring-tests.md +1 -1
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +31 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
23
|
-
?
|
|
32
|
+
const tapPoint = dismissalPoint
|
|
33
|
+
? { x: Math.round((dismissalPoint.x / scale) / dpr), y: Math.round((dismissalPoint.y / scale) / dpr) }
|
|
24
34
|
: null;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
227
|
-
model:
|
|
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
|
-
|
|
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 =
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
+
}
|