@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.
- package/README.md +123 -8
- package/build/index.js +46 -17
- package/build/src/cli/device-selector.js +13 -6
- package/build/src/cli/headless-execution-config.js +9 -0
- package/build/src/cli/headless-report.js +40 -11
- package/build/src/cli/headless-runner.js +28 -14
- 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 +15 -3
- package/build/src/commands/stop.js +1 -0
- package/build/src/core/capture-sources.js +4 -0
- package/build/src/core/droid-cua-run-usage.js +9 -2
- package/build/src/core/execution-context.js +8 -0
- package/build/src/core/execution-engine.js +3 -0
- package/build/src/core/report/render-html-report.js +299 -94
- package/build/src/core/report/run-log-model.js +84 -1
- package/build/src/core/report/run-report-model.js +4 -1
- package/build/src/core/report/run-report-sections.js +10 -0
- package/build/src/core/runtime-config.js +11 -1
- package/build/src/core/step-watchdog.js +61 -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/modes/execution-mode.js +157 -3
- 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/build/src/prompts/step-watchdog.js +22 -0
- package/build/src/utils/desktop-debug.js +2 -2
- 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 +33 -2
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|