@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
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import path from "path";
2
3
  import { mkdir, writeFile } from "fs/promises";
3
4
  import { getNetworkCaptureContext, navigateToUrl as navigateBrowserToUrl, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "../browser/connection.js";
@@ -9,6 +10,7 @@ import { buildExecutionModePrompt } from "../core/prompts.js";
9
10
  import { addOpenAiResponseToDroidCuaRunUsage, createDroidCuaRunUsageTotals, estimateDroidCuaRunCostUsd, setDroidCuaRunUsageModel, } from "../core/droid-cua-run-usage.js";
10
11
  import { getDroidVersion } from "../core/droid-version.js";
11
12
  import { ExecutionMode } from "../modes/execution-mode.js";
13
+ import { STEP_WATCHDOG_MODEL } from "../core/step-watchdog.js";
12
14
  import { buildDroidRunConfiguration, reportDroidRunCompletion, } from "../integrations/loadmill/run-reporter.js";
13
15
  import { droidReportUploadManager } from "../integrations/loadmill/report-upload-manager.js";
14
16
  import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-cua-segment.js";
@@ -28,10 +30,6 @@ function sanitizePathSegment(value) {
28
30
  .replace(/-+/g, "-")
29
31
  .replace(/^[-_.]+|[-_.]+$/g, "") || "test";
30
32
  }
31
- function createHeadlessRunId(index = 0) {
32
- const suffix = index > 0 ? `-${index + 1}` : "";
33
- return `run-${Date.now()}${suffix}`;
34
- }
35
33
  function networkLogPath(runId, report) {
36
34
  if (!report?.enabled)
37
35
  return path.resolve("logs", `network-${runId}.jsonl`);
@@ -73,14 +71,14 @@ function getRecordScreenshotDirForTest(baseScreenshotDir, testName, isBatchRun)
73
71
  }
74
72
  return path.join(baseScreenshotDir, sanitizePathSegment(testName));
75
73
  }
76
- function toDroidCuaSegmentRunStatus(success, error = null) {
74
+ function toDroidCuaSegmentRunStatus(success, error = null, failureKind = null) {
77
75
  if (success) {
78
76
  return "pass";
79
77
  }
80
78
  if (error === "Stopped by user") {
81
79
  return "skipped";
82
80
  }
83
- if (typeof error === "string" && error.startsWith("Assertion failed:")) {
81
+ if (failureKind === "step_watchdog" || (typeof error === "string" && error.startsWith("Assertion failed:"))) {
84
82
  return "failure";
85
83
  }
86
84
  return "error";
@@ -179,7 +177,7 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
179
177
  }
180
178
  return engineOptions;
181
179
  }
182
- async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
180
+ async function runHeadlessTest({ runId, captureIndex = 0, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
183
181
  let executionMode = null;
184
182
  let captureStarted = false;
185
183
  const finishCapture = async () => {
@@ -201,12 +199,13 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
201
199
  }
202
200
  };
203
201
  let loadmillSessionUser = null;
202
+ let stepWatchdogUsage = createDroidCuaRunUsageTotals({ model: STEP_WATCHDOG_MODEL });
204
203
  let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
205
204
  const buildSegmentUsageProperties = () => ({
206
205
  model: runUsageTotals.model,
207
206
  llmProviderMode: runtimeContext.config.llmProviderMode,
208
- estimatedCostUsd: estimateDroidCuaRunCostUsd(runUsageTotals),
209
- tokensUsed: runUsageTotals.totalTokens,
207
+ estimatedCostUsd: estimateDroidCuaRunCostUsd(runUsageTotals, stepWatchdogUsage),
208
+ tokensUsed: runUsageTotals.totalTokens + stepWatchdogUsage.totalTokens,
210
209
  });
211
210
  const reportSegmentRunCompleted = async (input) => {
212
211
  await reportDroidCuaCiTestRunCompleted({
@@ -288,7 +287,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
288
287
  if (networkCapture) {
289
288
  try {
290
289
  const browserContext = isBrowserTarget ? getNetworkCaptureContext(session.deviceId) : null;
291
- const capturePath = await networkCapture.startCapture(networkLogPath(runId, report), { browserContext });
290
+ const capturePath = await networkCapture.startCapture(networkLogPath(isBatchRun ? `${runId}-${captureIndex + 1}` : runId, report), { browserContext });
292
291
  console.log(`Network log: ${capturePath}`);
293
292
  captureStarted = true;
294
293
  engine.networkQuery = networkCapture.query;
@@ -309,6 +308,10 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
309
308
  loadmillClient: runtimeContext.loadmillClient,
310
309
  runtimeConfig: runtimeContext.config,
311
310
  runtimeCredentials: runtimeContext.credentials,
311
+ testDataContext,
312
+ onStepWatchdogResponse: (response) => {
313
+ stepWatchdogUsage = addOpenAiResponseToDroidCuaRunUsage(stepWatchdogUsage, response);
314
+ },
312
315
  // Inline screenshots as data URLs so the HTML report is self-contained.
313
316
  ...(reportEnabled ? { includeScreenshotDataUrl: true } : {}),
314
317
  });
@@ -318,6 +321,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
318
321
  await headlessDebug.endExecutionSession(runId, {
319
322
  success: Boolean(result.success),
320
323
  error: result.error ?? null,
324
+ failureKind: result.failureKind,
321
325
  durationMs,
322
326
  instructionsTotal: instructions.length,
323
327
  instructionsCompleted: stats.instructionsCompleted ?? 0,
@@ -327,7 +331,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
327
331
  retries: stats.retryCount ?? 0
328
332
  });
329
333
  await reportSegmentRunCompleted({
330
- status: toDroidCuaSegmentRunStatus(Boolean(result.success), result.error ?? null),
334
+ status: toDroidCuaSegmentRunStatus(Boolean(result.success), result.error ?? null, result.failureKind),
331
335
  durationMs,
332
336
  numOfSteps: instructions.length,
333
337
  });
@@ -338,6 +342,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
338
342
  loadmillClient: runtimeContext.loadmillClient,
339
343
  source: "ci",
340
344
  runId,
345
+ outcome: toDroidCuaSegmentRunStatus(Boolean(result.success), result.error ?? null, result.failureKind),
341
346
  projectName: path.basename(path.dirname(testPath)) || null,
342
347
  testName,
343
348
  runType: "test",
@@ -363,12 +368,17 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
363
368
  console.error(`\nTest failed: ${result.error}`);
364
369
  }
365
370
  const reportInput = {
371
+ deviceInfo,
366
372
  suiteName: path.basename(path.dirname(testPath)) || null,
367
373
  testName,
368
374
  startedAtIso: stats.startTime ? new Date(stats.startTime).toISOString() : null,
369
375
  durationMs,
370
376
  success: Boolean(result.success),
371
377
  error: result.error ?? null,
378
+ failureKind: result.failureKind,
379
+ ...(runtimeContext.config.stepWatchdogMode !== "off" && runtimeContext.config.stepWatchdogMode
380
+ ? { stepWatchdogMode: runtimeContext.config.stepWatchdogMode, stepWatchdogUsage }
381
+ : {}),
372
382
  instructionsTotal: instructions.length,
373
383
  stats,
374
384
  };
@@ -465,6 +475,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
465
475
  });
466
476
  }
467
477
  const failedReportInput = {
478
+ deviceInfo,
468
479
  suiteName: path.basename(path.dirname(testPath)) || null,
469
480
  testName,
470
481
  startedAtIso: stats.startTime ? new Date(stats.startTime).toISOString() : null,
@@ -518,7 +529,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
518
529
  throw error;
519
530
  }
520
531
  }
521
- export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget = false, desktopController = null, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
532
+ export async function runHeadlessInstructionRuns({ runId = randomUUID(), runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget = false, desktopController = null, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
522
533
  await clearLoadmillReportMetadata(report?.metadataPath);
523
534
  const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
524
535
  const runConfiguration = buildDroidRunConfiguration({
@@ -560,7 +571,6 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
560
571
  const aggregateLocalReportModels = [];
561
572
  const aggregateReportModels = [];
562
573
  for (const [index, run] of runs.entries()) {
563
- const runId = createHeadlessRunId(index);
564
574
  const testScreenshotDir = getRecordScreenshotDirForTest(screenshotDir, run.testName, isBatchRun);
565
575
  if (isBatchRun) {
566
576
  console.log(`\n[${index + 1}/${runs.length}] ${run.testName}`);
@@ -572,6 +582,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
572
582
  }
573
583
  const result = await runHeadlessTestImpl({
574
584
  runId,
585
+ captureIndex: index,
575
586
  testPath: run.testPath,
576
587
  testName: run.testName,
577
588
  instructions,
@@ -625,6 +636,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
625
636
  const message = error instanceof Error ? error.message : String(error);
626
637
  if (instructionsTarget?.type === "selection" && (report?.enabled || cloudReportEnabled)) {
627
638
  const failedReportModel = buildCliRunReportModel({
639
+ deviceInfo,
628
640
  suiteName: path.basename(path.dirname(run.testPath)) || null,
629
641
  testName: run.testName,
630
642
  startedAtIso: new Date().toISOString(),
@@ -670,6 +682,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
670
682
  : path.basename(instructionsTarget.path);
671
683
  if (isSelection && report?.enabled && aggregateLocalReportModels.length > 0) {
672
684
  const aggregateReport = createCliAggregateRunReport({
685
+ deviceInfo,
673
686
  suiteName: projectName,
674
687
  testName,
675
688
  startedAtIso: new Date(batchStartedAt).toISOString(),
@@ -691,7 +704,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
691
704
  ...runConfiguration,
692
705
  loadmillClient: runtimeContext.loadmillClient,
693
706
  source: "ci",
694
- runId: createHeadlessRunId(),
707
+ runId,
695
708
  projectName,
696
709
  testName,
697
710
  runType: "project",
@@ -715,6 +728,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
715
728
  });
716
729
  if (cloudUploadSession && reportedRun.savedRunId) {
717
730
  const aggregateReport = createCliAggregateRunReport({
731
+ deviceInfo,
718
732
  suiteName: projectName,
719
733
  testName,
720
734
  startedAtIso: new Date(batchStartedAt).toISOString(),
@@ -1,4 +1,5 @@
1
1
  import { CLOUD_BROWSER_FAMILIES, CLOUD_BROWSER_VERSION_ALIASES, LOCAL_BROWSER_FAMILIES, } from "../browser/cloud-browser-catalog.js";
2
+ import { parseIosDeviceToken } from "../device/ios/real-device/session.js";
2
3
  function normalizeTarget(value) {
3
4
  if (value == null || value === "") {
4
5
  return "mobile";
@@ -332,6 +333,9 @@ export function resolveHeadlessTarget(args = {}) {
332
333
  }
333
334
  if (deviceSource === "local") {
334
335
  assertNoCloudFlags(args);
336
+ if (platform === "android" && parseIosDeviceToken(avdName)) {
337
+ throw new Error("--avd ios-device:<udid> is an iPhone; use --platform ios or omit --platform.");
338
+ }
335
339
  }
336
340
  if (isCloudDeviceSource(deviceSource)) {
337
341
  const deviceName = hasNonEmptyString(args["device-name"]) ? args["device-name"].trim() : null;
@@ -393,3 +397,23 @@ export function resolveHeadlessTarget(args = {}) {
393
397
  listDevices: false,
394
398
  };
395
399
  }
400
+ const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/;
401
+ export function normalizeIosTeamId(value) {
402
+ const teamId = readNonEmptyCliValue(value)?.toUpperCase() ?? null;
403
+ if (!teamId || !APPLE_TEAM_ID_PATTERN.test(teamId)) {
404
+ throw new Error("--ios-team must be a 10-character Apple team ID, for example ABCDE12345.");
405
+ }
406
+ return teamId;
407
+ }
408
+ export function resolveIosTeamId(args = {}, targetConfig = {}) {
409
+ if (args["ios-team"] == null) {
410
+ return null;
411
+ }
412
+ const teamId = normalizeIosTeamId(args["ios-team"]);
413
+ const isLocalMobile = targetConfig.target === "mobile" && targetConfig.deviceSource === "local";
414
+ const isOtherDevice = targetConfig.avdName != null && !parseIosDeviceToken(targetConfig.avdName);
415
+ if (!isLocalMobile || targetConfig.platform === "android" || isOtherDevice) {
416
+ throw new Error("--ios-team can only be used with a local iPhone (--avd ios-device:<udid> or ios-device:auto).");
417
+ }
418
+ return teamId;
419
+ }
@@ -0,0 +1,121 @@
1
+ import { runChecks as defaultRunChecks } from "../device/ios/real-device/readiness.js";
2
+ import { IOS_DEVICE_PREFIX, parseIosDeviceToken } from "../device/ios/real-device/session.js";
3
+ import { createStatusLine as defaultCreateStatusLine } from "./cloud-status-line.js";
4
+ import { normalizeIosTeamId } from "./headless-target.js";
5
+ const ALLOWED_OPTIONS = new Set(["_", "udid", "fix", "ios-team", "h", "help"]);
6
+ const SYMBOLS = {
7
+ pass: { text: "✔", color: 32 },
8
+ fail: { text: "✖", color: 31 },
9
+ warn: { text: "!", color: 33 },
10
+ blocked: { text: "·", color: 90 },
11
+ };
12
+ export function iosDeviceCommandHelp() {
13
+ return [
14
+ "Usage: droid-cua ios-device check [options]",
15
+ "",
16
+ "Check that a real iPhone connected by USB is ready for droid-cua tests",
17
+ "",
18
+ "Options:",
19
+ " --udid <udid> Check this iPhone (default: the first iPhone connected by USB)",
20
+ " --ios-team <TEAMID> Apple team that signs the helper app (default: DROID_CUA_IOS_TEAM_ID, or your only paid team)",
21
+ " --fix Set up signing and install the helper app (WebDriverAgent) when needed",
22
+ " -h, --help display help for command",
23
+ "",
24
+ "Examples:",
25
+ " droid-cua ios-device check",
26
+ " droid-cua ios-device check --udid 00008120-001C18501A81A01E --ios-team ABCDE12345 --fix",
27
+ "",
28
+ "Run tests on the iPhone with --avd ios-device:<udid> or --avd ios-device:auto.",
29
+ ].join("\n");
30
+ }
31
+ function parseIosDeviceCommandArgs(args, env) {
32
+ const unknownOption = Object.keys(args).find((key) => !ALLOWED_OPTIONS.has(key));
33
+ if (unknownOption)
34
+ throw new Error(`Unknown ios-device option --${unknownOption}.`);
35
+ if (args.help === true || args.h === true || args._.length === 1) {
36
+ return { help: iosDeviceCommandHelp() };
37
+ }
38
+ if (args._[1] !== "check" || args._.length !== 2) {
39
+ throw new Error(`Unknown ios-device command "${args._.slice(1).join(" ")}". Expected: droid-cua ios-device check`);
40
+ }
41
+ if (args.fix != null && typeof args.fix !== "boolean") {
42
+ throw new Error("--fix does not accept a value.");
43
+ }
44
+ let udid = null;
45
+ if (args.udid != null) {
46
+ const value = typeof args.udid === "string" ? args.udid.trim() : "";
47
+ if (!value)
48
+ throw new Error("--udid requires a value.");
49
+ udid = parseIosDeviceToken(value) ? parseIosDeviceToken(value).udid : value;
50
+ }
51
+ const teamValue = args["ios-team"] ?? env.DROID_CUA_IOS_TEAM_ID;
52
+ const teamId = teamValue == null || teamValue === "" ? null : normalizeIosTeamId(teamValue);
53
+ return { udid, teamId, fix: args.fix === true };
54
+ }
55
+ function paint(text, code, color) {
56
+ return color ? `\u001b[${code}m${text}\u001b[39m` : text;
57
+ }
58
+ function symbolFor(result, color) {
59
+ const symbol = SYMBOLS[result.blockedBy ? "blocked" : result.status];
60
+ return paint(symbol.text, symbol.color, color);
61
+ }
62
+ function guidanceLines(result, { fix }) {
63
+ if (result.blockedBy || result.status === "pass")
64
+ return [];
65
+ const lines = [];
66
+ if (result.details) {
67
+ lines.push(result.status === "fail" ? `Fix: ${result.details}` : result.details);
68
+ }
69
+ if (result.id === "apple_team" && result.status === "fail" && /paid Apple teams/.test(result.summary)) {
70
+ lines.push("Choose the team with --ios-team <TEAMID> (or set DROID_CUA_IOS_TEAM_ID).");
71
+ }
72
+ if (!fix && result.remediation?.some((step) => step.kind === "run_fix")) {
73
+ lines.push("Run droid-cua ios-device check --fix to do it now (a test run also does it automatically).");
74
+ }
75
+ return lines.map((line) => ` ${line}`);
76
+ }
77
+ export function formatReadinessReport({ ready, results, context }, { fix = false, color = false } = {}) {
78
+ const device = context?.device;
79
+ const heading = device
80
+ ? `iPhone readiness: ${device.name} (${device.model}, iOS ${device.iosVersion})`
81
+ : "iPhone readiness";
82
+ const lines = [heading, ""];
83
+ for (const result of results) {
84
+ lines.push(`${symbolFor(result, color)} ${result.title}: ${result.summary}`);
85
+ lines.push(...guidanceLines(result, { fix }));
86
+ }
87
+ lines.push("");
88
+ if (ready) {
89
+ lines.push(`Ready. Run tests on this iPhone with --avd ${IOS_DEVICE_PREFIX}${context.udid}`);
90
+ }
91
+ else {
92
+ lines.push("Not ready. Fix the first ✖ above, then run droid-cua ios-device check again.");
93
+ }
94
+ return lines;
95
+ }
96
+ function shouldUseColor() {
97
+ return Boolean(process.stdout.isTTY) && !Object.hasOwn(process.env, "NO_COLOR");
98
+ }
99
+ export async function runIosDeviceCommand({ args, env = process.env, output = console, runChecks = defaultRunChecks, createStatusLine = defaultCreateStatusLine, color = shouldUseColor(), } = {}) {
100
+ const command = parseIosDeviceCommandArgs(args ?? { _: ["ios-device"] }, env);
101
+ if (command.help) {
102
+ output.log(command.help);
103
+ return 0;
104
+ }
105
+ const statusLine = createStatusLine();
106
+ statusLine.start(command.fix ? "Checking and preparing the iPhone" : "Checking the iPhone");
107
+ let result;
108
+ try {
109
+ result = await runChecks({
110
+ udid: command.udid,
111
+ teamId: command.teamId,
112
+ autoFix: command.fix,
113
+ onProgress: (progress) => statusLine.update(progress.message),
114
+ });
115
+ }
116
+ finally {
117
+ statusLine.stop();
118
+ }
119
+ output.log(formatReadinessReport(result, { fix: command.fix, color }).join("\n"));
120
+ return result.ready ? 0 : 1;
121
+ }
@@ -0,0 +1,32 @@
1
+ import { listDevices } from "../device/ios/real-device/devicectl.js";
2
+ import { IOS_DEVICE_PREFIX } from "../device/ios/real-device/session.js";
3
+ export function iosDeviceStateHint(device) {
4
+ if (device.pairingState !== "paired")
5
+ return "not trusted";
6
+ if (device.developerModeStatus === "disabled")
7
+ return "Developer Mode off";
8
+ return null;
9
+ }
10
+ export function iosDeviceLabel(device) {
11
+ const details = [device.model, device.iosVersion ? `iOS ${device.iosVersion}` : null].filter(Boolean).join(" · ");
12
+ const hint = iosDeviceStateHint(device);
13
+ return `${device.name || device.udid} — ${details ? `${details} ` : ""}(USB)${hint ? ` — ${hint}` : ""}`;
14
+ }
15
+ export async function getConnectedIphones({ list = listDevices } = {}) {
16
+ let devices;
17
+ try {
18
+ devices = await list();
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ return devices
24
+ .filter((device) => device.transport === "wired" && device.udid)
25
+ .map((device) => ({
26
+ label: iosDeviceLabel(device),
27
+ value: `${IOS_DEVICE_PREFIX}${device.udid}`,
28
+ running: true,
29
+ realDevice: true,
30
+ }))
31
+ .sort((a, b) => a.label.localeCompare(b.label));
32
+ }
@@ -0,0 +1,21 @@
1
+ import { parseIosDeviceToken } from "../device/ios/real-device/session.js";
2
+ import { androidProxyInstruction, iphoneProxyInstruction, LAN_PROXY_HOST, macLanAddresses, NETWORK_PROXY_HOST } from "../network/local-proxy.js";
3
+ function isAndroidEmulatorSerial(serial) {
4
+ return /^emulator-\d+$/.test(serial || "");
5
+ }
6
+ function physicalAndroidSerial(deviceName) {
7
+ const serial = typeof deviceName === "string" && deviceName.startsWith("adb:") ? deviceName.slice(4) : null;
8
+ return serial && !isAndroidEmulatorSerial(serial) ? serial : null;
9
+ }
10
+ export function cliNetworkProxyTarget(deviceName, addresses = macLanAddresses()) {
11
+ if (parseIosDeviceToken(deviceName))
12
+ return { host: LAN_PROXY_HOST, instruction: iphoneProxyInstruction(addresses) };
13
+ if (physicalAndroidSerial(deviceName))
14
+ return { host: LAN_PROXY_HOST, instruction: androidProxyInstruction(addresses) };
15
+ return { host: NETWORK_PROXY_HOST, instruction: null };
16
+ }
17
+ export function cliNetworkCaptureDeviceKind(platform, targetId) {
18
+ if (platform === "android")
19
+ return isAndroidEmulatorSerial(targetId) ? "android-emulator" : "android-device";
20
+ return parseIosDeviceToken(targetId) ? "iphone" : "ios-simulator";
21
+ }
@@ -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' });
@@ -54,8 +56,9 @@ export async function handleHelp(args, session, context) {
54
56
  addOutput({ type: 'info', text: ' --execution-prompt-file Headless execution prompt customization file' });
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' });
59
+ addOutput({ type: 'info', text: ' --step-watchdog <off|on> Check for failures after each instruction: off (default), on' });
57
60
  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' });
61
+ addOutput({ type: 'info', text: ' --network-proxy Forward local mobile traffic through port 8765 without capture (all Mac addresses for iPhones and Android phones)' });
59
62
  addOutput({ type: 'info', text: ' --capture-network Capture local mobile or browser test traffic to one JSONL log per test' });
60
63
  addOutput({ type: 'info', text: ' --no-cloud-report Skip uploading the HTML report and screenshots to Loadmill' });
61
64
  addOutput({ type: 'info', text: ' --report-metadata <path> Write uploaded Loadmill report metadata as JSON' });
@@ -77,7 +80,7 @@ export async function handleHelp(args, session, context) {
77
80
  addOutput({ type: 'info', text: '' });
78
81
  addOutput({ type: 'info', text: 'Platform Support:' });
79
82
  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' });
83
+ addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulators and iPhones connected by USB' });
81
84
  addOutput({ type: 'info', text: ' LambdaTest: Uses real cloud devices for headless mobile runs' });
82
85
  addOutput({ type: 'info', text: ' Web: Uses Playwright with installed Google Chrome or Microsoft Edge for browser execution' });
83
86
  addOutput({ type: 'info', text: ' Desktop: Controls the primary display or a selected application on macOS and Windows' });
@@ -94,11 +97,20 @@ export async function handleHelp(args, session, context) {
94
97
  addOutput({ type: 'info', text: ' 3. XCUITest driver: appium driver install xcuitest' });
95
98
  addOutput({ type: 'info', text: ' Note: Appium server is auto-started when iOS platform is detected' });
96
99
  addOutput({ type: 'info', text: '' });
100
+ addOutput({ type: 'info', text: 'iPhone over USB (macOS only):' });
101
+ addOutput({ type: 'info', text: ' 1. Xcode that supports the iPhone\'s iOS, opened once' });
102
+ addOutput({ type: 'info', text: ' 2. Your Apple ID in Xcode, on your company\'s paid Apple Developer team' });
103
+ addOutput({ type: 'info', text: ' (Developer role with Access to Certificates, Identifiers & Profiles)' });
104
+ addOutput({ type: 'info', text: ' 3. On the iPhone: tap Trust, turn on Developer Mode, set Auto-Lock to Never' });
105
+ addOutput({ type: 'info', text: ' 4. droid-cua ios-device check --fix (checks the iPhone and installs the helper app)' });
106
+ addOutput({ type: 'info', text: '' });
97
107
  addOutput({ type: 'info', text: 'Examples:' });
98
108
  addOutput({ type: 'info', text: ' droid-cua --avd adb:emulator-5554 (Connected Android target by adb serial)' });
99
109
  addOutput({ type: 'info', text: ' droid-cua --avd avd:Pixel_8_API_35 (Launch Android AVD then connect)' });
100
110
  addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
101
111
  addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
112
+ addOutput({ type: 'info', text: ' droid-cua run tests/login.dcua --avd ios-device:auto (iPhone connected by USB)' });
113
+ addOutput({ type: 'info', text: ' droid-cua run tests/login.dcua --avd ios-device:<udid> --ios-team ABCDE12345' });
102
114
  addOutput({ type: 'info', text: ' droid-cua run tests/login.dcua admin/refund.dcua --context app/context.md' });
103
115
  addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
104
116
  addOutput({ type: 'info', text: ' droid-cua --instructions tests --test-data tests/test-data.md --scenario "Run every checkout test with each buyer account"' });
@@ -23,6 +23,7 @@ export async function handleStop(args, session, context) {
23
23
  // Set flag to stop execution
24
24
  if (context.activeExecutionMode) {
25
25
  context.activeExecutionMode.shouldStop = true;
26
+ context.activeExecutionMode.requestStop?.();
26
27
  }
27
28
  return true; // Continue loop
28
29
  }
@@ -20,15 +20,19 @@ export const CAPTURE_SOURCES = Object.freeze({
20
20
  CALL_OUTPUT_EXPLICIT_ACTION: "call-output-explicit-action",
21
21
  INSTRUCTION_INPUT: "instruction-input",
22
22
  INSTRUCTION_INPUT_ASSERTION: "instruction-input-assertion",
23
+ STEP_WATCHDOG: "step-watchdog",
24
+ STEP_FAILURE: "step-failure",
23
25
  DESIGN_INPUT: "design-input",
24
26
  });
25
27
  const HIDDEN_DEBUG_MEDIA_KINDS = new Set([
26
28
  CAPTURE_SOURCES.PREVIEW_CLICK,
29
+ CAPTURE_SOURCES.STEP_WATCHDOG,
27
30
  ]);
28
31
  const CLICK_ANNOTATION_MEDIA_KINDS = new Set([
29
32
  CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
30
33
  ]);
31
34
  const SCREENSHOT_MEDIA_KINDS = new Set([
35
+ CAPTURE_SOURCES.STEP_FAILURE,
32
36
  CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
33
37
  CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION,
34
38
  CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION,
@@ -61,6 +61,7 @@ export function normalizeDroidCuaCostModel(model) {
61
61
  }
62
62
  return null;
63
63
  }
64
+ /** @param {{ model?: string | null }} [options] */
64
65
  export function createDroidCuaRunUsageTotals({ model = null } = {}) {
65
66
  const normalizedModel = trimToNull(model);
66
67
  return {
@@ -110,7 +111,11 @@ export function addOpenAiResponseToDroidCuaRunUsage(totals, response) {
110
111
  totalTokens: totals.totalTokens + totalTokens,
111
112
  };
112
113
  }
113
- export function estimateDroidCuaRunCostUsd(totals) {
114
+ /**
115
+ * @param {ReturnType<typeof createDroidCuaRunUsageTotals>} totals
116
+ * @param {{ inputTokens: number } | null} [stepWatchdogUsage]
117
+ */
118
+ export function estimateDroidCuaRunCostUsd(totals, stepWatchdogUsage = null) {
114
119
  const costModel = totals.costModel ?? normalizeDroidCuaCostModel(totals.model);
115
120
  if (!costModel) {
116
121
  return null;
@@ -119,6 +124,8 @@ export function estimateDroidCuaRunCostUsd(totals) {
119
124
  if (!pricing) {
120
125
  return null;
121
126
  }
122
- const cost = (totals.inputTokens / TOKENS_PER_MILLION) * pricing.inputPerMillionUsd;
127
+ // Decisions charges input tokens only; keep its usage separate from the actor model.
128
+ const cost = (totals.inputTokens / TOKENS_PER_MILLION) * pricing.inputPerMillionUsd
129
+ + (finiteNumber(stepWatchdogUsage?.inputTokens) / TOKENS_PER_MILLION) * 0.1;
123
130
  return Number(cost.toFixed(8));
124
131
  }
@@ -0,0 +1,8 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const executionRunId = new AsyncLocalStorage();
3
+ export function withExecutionRunId(runId, callback) {
4
+ return executionRunId.run(runId, callback);
5
+ }
6
+ export function getExecutionRunId() {
7
+ return executionRunId.getStore();
8
+ }
@@ -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
  }