@loadmill/droid-cua 2.43.0 → 2.45.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 (46) hide show
  1. package/README.md +123 -8
  2. package/build/index.js +46 -17
  3. package/build/src/cli/device-selector.js +13 -6
  4. package/build/src/cli/headless-execution-config.js +9 -0
  5. package/build/src/cli/headless-report.js +40 -11
  6. package/build/src/cli/headless-runner.js +28 -14
  7. package/build/src/cli/headless-target.js +24 -0
  8. package/build/src/cli/ios-device-command.js +121 -0
  9. package/build/src/cli/ios-device-list.js +32 -0
  10. package/build/src/cli/network-capture-device.js +21 -0
  11. package/build/src/commands/help.js +15 -3
  12. package/build/src/commands/stop.js +1 -0
  13. package/build/src/core/capture-sources.js +4 -0
  14. package/build/src/core/droid-cua-run-usage.js +9 -2
  15. package/build/src/core/execution-context.js +8 -0
  16. package/build/src/core/execution-engine.js +3 -0
  17. package/build/src/core/report/render-html-report.js +299 -94
  18. package/build/src/core/report/run-log-model.js +84 -1
  19. package/build/src/core/report/run-report-model.js +4 -1
  20. package/build/src/core/report/run-report-sections.js +10 -0
  21. package/build/src/core/runtime-config.js +11 -1
  22. package/build/src/core/step-watchdog.js +61 -0
  23. package/build/src/desktop-shared/ios-real-device.js +82 -0
  24. package/build/src/device/factory.js +4 -0
  25. package/build/src/device/ios/actions.js +48 -12
  26. package/build/src/device/ios/appium-server.js +7 -18
  27. package/build/src/device/ios/connection.js +71 -12
  28. package/build/src/device/ios/node-cli-env.js +55 -0
  29. package/build/src/device/ios/real-device/device-state.js +40 -0
  30. package/build/src/device/ios/real-device/devicectl.js +154 -0
  31. package/build/src/device/ios/real-device/readiness.js +439 -0
  32. package/build/src/device/ios/real-device/session.js +176 -0
  33. package/build/src/device/ios/real-device/signing.js +466 -0
  34. package/build/src/device/ios/real-device/wda-provisioning.js +229 -0
  35. package/build/src/modes/execution-mode.js +157 -3
  36. package/build/src/network/certificate-authority.js +21 -1
  37. package/build/src/network/certificate-install.js +38 -0
  38. package/build/src/network/local-proxy.js +21 -1
  39. package/build/src/network/run-network-capture.js +16 -5
  40. package/build/src/prompts/step-watchdog.js +22 -0
  41. package/build/src/utils/desktop-debug.js +2 -2
  42. package/package.json +1 -1
  43. package/skill/droid-cua/SKILL.md +2 -2
  44. package/skill/droid-cua/references/authoring-tests.md +1 -1
  45. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +33 -2
  46. package/skill/droid-cua/references/running-and-healing.md +27 -2
@@ -6,6 +6,37 @@ export function isNetworkCaptureStarted(line) {
6
6
  return (line.kind === "system" || line.kind === "info") &&
7
7
  /^Network capture started(?::|$)/.test(line.text || "");
8
8
  }
9
+ // Run-level capture notices belong in the report header, not instruction actions.
10
+ export function isNetworkCaptureNotice(line) {
11
+ return ["system", "info", "warning"].includes(line.kind) &&
12
+ /^(Network capture (?:started|incomplete|could not finish)|Captured \d+ (?:browser )?requests|No browser traffic observed|No traffic observed;|HTTPS reached the proxy)/i.test(line.text || "");
13
+ }
14
+ // Presentation shared by desktop and exported reports; original events remain intact.
15
+ export function runNotice(line) {
16
+ const text = line.text || "";
17
+ if (!["warning", "info", "system"].includes(line.kind))
18
+ return null;
19
+ if (/^Network capture (?:incomplete|could not finish):/.test(text)) {
20
+ return { message: "Network capture couldn’t finish. Some traffic may be missing.", details: text.replace(/^Network capture (?:incomplete|could not finish):\s*/, ""), warning: true };
21
+ }
22
+ if (text.startsWith("HTTPS reached the proxy but no requests were decrypted")) {
23
+ return { message: "HTTPS traffic couldn’t be decrypted. Check certificate trust on the device.", details: text, warning: true };
24
+ }
25
+ if (line.kind === "warning" && text.startsWith("Warning: could not load app context")) {
26
+ return { message: "App context couldn’t be loaded. This run continued without it.", warning: true };
27
+ }
28
+ const reportFailure = /^Failed to (write JUnit report|report run to Loadmill):\s*(.*)$/s.exec(text);
29
+ if (line.kind === "warning" && reportFailure) {
30
+ return { message: reportFailure[1] === "write JUnit report" ? "Couldn’t save the run report." : "Couldn’t report this run to Loadmill.", details: reportFailure[2], warning: true };
31
+ }
32
+ if (line.kind === "warning" && text === "Failed to upload Droid run report. The local report is still available.") {
33
+ return { message: "Couldn’t upload the report to Loadmill. The local report is still available.", warning: true };
34
+ }
35
+ if (text === "Network Capture supports local mobile devices and Chrome/Edge browsers only; this run continues without capture.") {
36
+ return { message: "Network capture isn’t supported on this device. This run continued without it.", warning: false };
37
+ }
38
+ return null;
39
+ }
9
40
  export function captureSourceOf(line) {
10
41
  const captureSource = line.payload?.captureSource;
11
42
  return typeof captureSource === "string" && captureSource.trim().length > 0 ? captureSource : null;
@@ -90,7 +121,7 @@ export function runSummaryData(event) {
90
121
  const success = Boolean(payload.success);
91
122
  const error = typeof payload.error === "string" && payload.error.trim().length > 0 ? payload.error : null;
92
123
  const stopped = !success && Boolean(error && error.toLowerCase().includes("stopped by user"));
93
- const isAssertionFailure = Boolean(error && error.startsWith("Assertion failed:"));
124
+ const isAssertionFailure = payload.failureKind === "step_watchdog" || Boolean(error && error.startsWith("Assertion failed:"));
94
125
  return {
95
126
  status: success ? "passed" : stopped ? "stopped" : isAssertionFailure ? "failed" : "errored",
96
127
  durationMs: typeof payload.durationMs === "number" ? payload.durationMs : null,
@@ -255,3 +286,55 @@ export function networkCardData(lines) {
255
286
  answer: networkAnswerPresentation(answer),
256
287
  };
257
288
  }
289
+ export function stepFailureData(event) {
290
+ if (event.eventType !== "error" && event.kind !== "error")
291
+ return null;
292
+ // Older saved runs identify this failure only by its message.
293
+ if (event.payload?.failureKind !== "step_watchdog" && event.text !== "Execution stopped: the last step could not be completed as instructed.")
294
+ return null;
295
+ const instruction = typeof event.payload?.instruction === "string" ? event.payload.instruction.trim() : "";
296
+ const details = typeof event.payload?.details === "string" ? event.payload.details.trim() : "";
297
+ return {
298
+ title: "Step Failed",
299
+ passed: false,
300
+ assertion: instruction || "Complete this step as instructed.",
301
+ details: stepCompletionData(details)?.reason || details || "This step could not be completed as instructed.",
302
+ };
303
+ }
304
+ export function isRepeatedStepFailureMessage(events, index) {
305
+ const event = events[index];
306
+ if (event.eventType !== "assistant_message" && event.kind !== "assistant")
307
+ return false;
308
+ for (let i = index + 1; i < events.length; i++) {
309
+ const next = events[i];
310
+ if (next.eventType === "instruction_started" || next.kind === "user" || next.eventType === "run_finished")
311
+ break;
312
+ if (stepFailureData(next) && next.payload?.details === event.text.trim())
313
+ return true;
314
+ }
315
+ return false;
316
+ }
317
+ // A run's final error often repeats an error or assertion already shown in the timeline.
318
+ export function isRepeatedRunError(error, events) {
319
+ if (!error)
320
+ return false;
321
+ return events.some((event) => {
322
+ if (event.eventType !== "run_finished" && (event.eventType === "error" || event.kind === "error") && event.text === error)
323
+ return true;
324
+ const assertion = assertionData(event);
325
+ return assertion?.passed === false && (error === `Assertion failed: ${assertion.assertion}` ||
326
+ error === assertion.details);
327
+ });
328
+ }
329
+ // Counts from child completions, excluding the aggregate suite completion event.
330
+ export function runSummaryCounts(events) {
331
+ const results = events.filter((event) => event.eventType === "run_finished" && typeof event.payload?.testsTotal !== "number").map(runSummaryData);
332
+ if (!results.length)
333
+ return null;
334
+ const counts = { instructionsTotal: 0, instructionsCompleted: 0, assertionsPassed: 0, assertionsFailed: 0, retries: 0 };
335
+ for (const result of results) {
336
+ for (const key of Object.keys(counts))
337
+ counts[key] += result[key] ?? 0;
338
+ }
339
+ return counts;
340
+ }
@@ -29,6 +29,7 @@ const VALID_EVENTS = new Set([
29
29
  "tool_result",
30
30
  "screenshot_captured",
31
31
  "assertion_result",
32
+ "step_watchdog_result",
32
33
  "retry",
33
34
  "input_request",
34
35
  "run_finished",
@@ -112,10 +113,12 @@ export function makeFailureOrError({ kind, type, message, body } = {}) {
112
113
  }
113
114
  // The single run_finished payload contract — the shape the shared renderer's
114
115
  // runSummaryData() consumes. Producers map their own stat names to these canonical fields.
115
- export function buildRunFinishedPayload({ success, error, durationMs, instructionsTotal, instructionsCompleted, actionsTotal, assertionsPassed, assertionsFailed, retries, } = {}) {
116
+ export function buildRunFinishedPayload({ success, error, failureKind, stepWatchdogMode, stepWatchdogUsage, durationMs, instructionsTotal, instructionsCompleted, actionsTotal, assertionsPassed, assertionsFailed, retries, } = {}) {
116
117
  return {
117
118
  success: Boolean(success),
118
119
  error: error ?? null,
120
+ ...(failureKind ? { failureKind } : {}),
121
+ ...(stepWatchdogMode ? { stepWatchdogMode, stepWatchdogUsage } : {}),
119
122
  durationMs,
120
123
  instructionsTotal,
121
124
  instructionsCompleted: instructionsCompleted ?? 0,
@@ -1,3 +1,4 @@
1
+ import { isNetworkCaptureStarted } from "./run-log-model.js";
1
2
  const CLOUD_PROVIDERS = [
2
3
  ["browserstack", "BrowserStack", "BrowserStack"],
3
4
  ["lambdatest", "LambdaTest", "LambdaTest"],
@@ -149,3 +150,12 @@ export function buildRunReportSections(logs) {
149
150
  }
150
151
  return sections;
151
152
  }
153
+ // Stable order, independent of execution order; each activity appears only once.
154
+ export function instructionActivities(lines) {
155
+ const sections = buildRunReportSections(lines);
156
+ return [
157
+ { type: "assertion", label: "Assertion", present: lines.some((line) => line.eventType === "assertion_result") },
158
+ { type: "loadmill", label: "Loadmill flow", present: sections.some((section) => section.kind === "loadmill") },
159
+ { type: "network", label: "Network capture / query", present: lines.some(isNetworkCaptureStarted) || sections.some((section) => section.kind === "network") },
160
+ ].filter((activity) => activity.present);
161
+ }
@@ -1,4 +1,6 @@
1
+ import { getExecutionRunId } from "./execution-context.js";
1
2
  import OpenAI from "openai";
3
+ import { parseStepWatchdogMode } from "./step-watchdog.js";
2
4
  import { normalizeOptionalAutomationUrl } from "../browser/playwright/url.js";
3
5
  import { readContextOptimizationEnv, normalizeContextOptimizationEnabled, normalizeContextOptimizationThreshold, } from "./context-optimization.js";
4
6
  import { DEFAULT_OPENAI_CUA_MODEL, isLoadmillCuaModel, resolveCuaModel, resolveCuaModelProviderMode, } from "./cua-models.js";
@@ -123,7 +125,7 @@ export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToke
123
125
  loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
124
126
  };
125
127
  }
126
- export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, explainMode = true, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
128
+ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, explainMode = true, stepWatchdogMode = "off", stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
127
129
  const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
128
130
  ? cuaModel
129
131
  : resolveCuaModel(cuaModel);
@@ -134,6 +136,7 @@ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null,
134
136
  : normalizedProviderMode,
135
137
  cuaModel: normalizedCuaModel,
136
138
  explainMode: explainMode !== false,
139
+ stepWatchdogMode: parseStepWatchdogMode(stepWatchdogMode),
137
140
  stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
138
141
  contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
139
142
  contextOptimizationThreshold: normalizeContextOptimizationThreshold(contextOptimizationThreshold),
@@ -237,6 +240,13 @@ async function normalizeLoadmillGatewayErrorResponse(response) {
237
240
  }
238
241
  function createLoadmillGatewayFetch(baseFetch = fetch) {
239
242
  return async (input, init) => {
243
+ const runId = getExecutionRunId();
244
+ if (runId) {
245
+ const headers = new Headers(input instanceof Request ? input.headers : undefined);
246
+ new Headers(init?.headers).forEach((value, key) => headers.set(key, value));
247
+ headers.set("X-Loadmill-Run-Id", runId);
248
+ init = { ...init, headers };
249
+ }
240
250
  const response = await baseFetch(input, init);
241
251
  if (response.ok) {
242
252
  return response;
@@ -0,0 +1,61 @@
1
+ import { buildStepWatchdogEvidence, STEP_WATCHDOG_INSTRUCTIONS } from "../prompts/step-watchdog.js";
2
+ export const STEP_WATCHDOG_MODES = ["off", "on"];
3
+ export const STEP_WATCHDOG_MODEL = "gpt-6-luna";
4
+ // Provisional threshold; calibrate against labeled runs before enabling by default.
5
+ export const STEP_WATCHDOG_THRESHOLD = 0.65;
6
+ export const STEP_WATCHDOG_TIMEOUT_MS = 10000;
7
+ /** @param {unknown} [value] */
8
+ export function parseStepWatchdogMode(value = "off") {
9
+ if (!STEP_WATCHDOG_MODES.includes(value)) {
10
+ throw new Error("stepWatchdogMode must be off or on.");
11
+ }
12
+ return value;
13
+ }
14
+ export async function evaluateStepWatchdog({ client, screenshotBase64, signal, onResponse, secretsByKey = {}, ...evidence }) {
15
+ if (!screenshotBase64)
16
+ throw new Error("Step Watchdog requires a current screenshot.");
17
+ let text = buildStepWatchdogEvidence(evidence);
18
+ for (const value of Object.values(secretsByKey).filter((value) => typeof value === "string" && value).sort((a, b) => b.length - a.length)) {
19
+ text = text.replaceAll(JSON.stringify(value).slice(1, -1), "[REDACTED]");
20
+ }
21
+ let response;
22
+ try {
23
+ response = await client.post("/decisions", {
24
+ body: {
25
+ model: STEP_WATCHDOG_MODEL,
26
+ input: [{ role: "user", content: [
27
+ { type: "input_text", text },
28
+ { type: "input_image", image_url: `data:image/png;base64,${screenshotBase64}` },
29
+ ] }],
30
+ questions: [{ type: "predicate", name: "should_stop", instructions: STEP_WATCHDOG_INSTRUCTIONS }],
31
+ },
32
+ timeout: STEP_WATCHDOG_TIMEOUT_MS,
33
+ maxRetries: 0,
34
+ signal,
35
+ });
36
+ }
37
+ catch (error) {
38
+ if (signal?.aborted)
39
+ throw error;
40
+ if (error?.status === 404 || error?.status === 405) {
41
+ throw new Error("The selected AI provider does not support the Decisions endpoint. Loadmill runs require gateway support for /decisions.");
42
+ }
43
+ if (error?.name === "APIConnectionTimeoutError")
44
+ throw new Error("Step Watchdog timed out after 10 seconds.");
45
+ throw new Error(`Step Watchdog request failed${Number.isInteger(error?.status) ? ` (HTTP ${error.status})` : ""}. Check the selected AI provider's connection, credentials, and Decisions support.`);
46
+ }
47
+ onResponse?.(response);
48
+ const answers = Array.isArray(response?.answers) ? response.answers : [];
49
+ const answer = answers.find((item) => item?.name === "should_stop");
50
+ if (answer?.type === "refusal")
51
+ throw new Error("The Step Watchdog evaluator declined to answer.");
52
+ if (answers.length !== 1 || answer?.type !== "predicate" || !Number.isFinite(answer.probability) || answer.probability < 0 || answer.probability > 1) {
53
+ throw new Error("The Step Watchdog evaluator returned an invalid decision.");
54
+ }
55
+ return {
56
+ probability: answer.probability,
57
+ shouldStop: answer.probability >= STEP_WATCHDOG_THRESHOLD,
58
+ model: response.model || STEP_WATCHDOG_MODEL,
59
+ usage: response.usage,
60
+ };
61
+ }
@@ -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}