@loadmill/droid-cua 2.25.0 → 2.26.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 CHANGED
@@ -48,7 +48,10 @@ Open the desktop app and choose the platform you want to test.
48
48
 
49
49
  **3. Choose your AI provider**
50
50
 
51
- In Settings, choose the CUA model you want to run. `Loadmill - Smart` uses your Loadmill sign-in and usage tracking. The OpenAI models use your own OpenAI API key.
51
+ In Settings, choose the CUA model you want to run. Loadmill Smart is the
52
+ recommended option, Pulse is fast and efficient, and Beacon is experimental.
53
+ The Loadmill models use your Loadmill sign-in and usage tracking. The OpenAI
54
+ models use your own OpenAI API key.
52
55
 
53
56
  **4. Connect a target device**
54
57
 
@@ -75,7 +78,7 @@ Desktop app support:
75
78
  ## Requirements
76
79
 
77
80
  **All platforms:**
78
- - Loadmill sign-in for `Loadmill - Smart`, or an OpenAI API key for the OpenAI models
81
+ - Loadmill sign-in for the Loadmill models, or an OpenAI API key for the OpenAI models
79
82
 
80
83
  **Android:**
81
84
  - Android Debug Bridge (ADB)
@@ -256,9 +259,9 @@ Supported CLI options include:
256
259
  - `--report`
257
260
  - `--debug`
258
261
 
259
- `--cua-model` accepts `loadmill-smart`, `loadmill-beacon`, `gpt-5.6-terra`,
260
- `gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI mode and require
261
- `OPENAI_API_KEY`.
262
+ `--cua-model` accepts `loadmill-smart`, `loadmill-pulse`, `loadmill-beacon`,
263
+ `gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI
264
+ mode and require `OPENAI_API_KEY`.
262
265
 
263
266
  Config and precedence rules:
264
267
  - Use `--config <file>` to supply prompt-affecting settings for headless runs.
package/build/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ const processStartTime = Date.now();
1
2
  import minimist from "minimist";
2
3
  import dotenv from "dotenv";
3
4
  import path from "path";
@@ -17,7 +18,7 @@ import { resolveExecutionTestData } from "./src/core/test-data.js";
17
18
  import { getDefaultSecretsPath, resolveExecutionSecrets } from "./src/core/secrets.js";
18
19
  import { assertRuntimeCredentialsForProvider, createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
19
20
  import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
20
- import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
21
+ import { resolveHeadlessTarget, shouldWaitForLoadmillCloudCompletion } from "./src/cli/headless-target.js";
21
22
  import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
22
23
  import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
23
24
  import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from "./src/cli/headless-instruction-target.js";
@@ -25,12 +26,32 @@ import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
25
26
  import { planScenarioRuns } from "./src/cli/scenario-planner.js";
26
27
  import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
27
28
  import { resolveAwsDeviceFarmCredentials, uploadAwsDeviceFarmCliApp } from "./src/cli/aws-device-farm-app-upload.js";
29
+ import { resolveLoadmillCloudCredentials } from "./src/cli/loadmill-cloud-credentials.js";
30
+ import { formatCloudUsageReport } from "./src/cli/loadmill-cloud-usage.js";
31
+ import { LoadmillCloudControlPlaneClient } from "./src/device/cloud/loadmill-cloud/control-plane-client.js";
28
32
  import { createStatusLine } from "./src/cli/cloud-status-line.js";
29
33
  import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
30
34
  import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
31
35
  import { setCloudProgressListener } from "./src/device/cloud/progress.js";
32
36
  import { runSkillCommand } from "./src/cli/skill-command.js";
37
+ import { isLoadmillCloudUsageLimitError } from "./src/device/cloud/loadmill-cloud/errors.js";
38
+ import { loadmillCloudAdapter } from "./src/device/cloud/loadmill-cloud/adapter.js";
39
+ import { formatDuration } from "./src/core/report/run-log-model.js";
33
40
  dotenv.config();
41
+ process.once("SIGTERM", async () => {
42
+ process.exitCode = 143;
43
+ // Registering a SIGTERM listener cancels Node's default terminate, so exit
44
+ // explicitly; await the /stop because a bare fire-and-forget fetch may not
45
+ // dispatch before exit. Capped so a slow server can't hang the exit.
46
+ await Promise.race([
47
+ loadmillCloudAdapter.releaseActiveSession(),
48
+ new Promise((resolve) => setTimeout(resolve, 1_500)),
49
+ ]);
50
+ process.exit(143);
51
+ });
52
+ process.once("beforeExit", () => {
53
+ loadmillCloudAdapter.releaseActiveSession();
54
+ });
34
55
  const args = minimist(process.argv.slice(2));
35
56
  if (args._[0] === "skill") {
36
57
  try {
@@ -173,6 +194,84 @@ async function resolveLambdaTestDashboardUrl(credentials, sessionId, { attempts
173
194
  return null;
174
195
  }
175
196
  async function main() {
197
+ if (args["cloud-usage"] != null) {
198
+ try {
199
+ const credentials = resolveLoadmillCloudCredentials(process.env);
200
+ const client = new LoadmillCloudControlPlaneClient({
201
+ bearerToken: credentials.bearerToken,
202
+ baseUrl: credentials.baseUrl,
203
+ });
204
+ const usage = await client.getUsage();
205
+ for (const line of formatCloudUsageReport(usage)) {
206
+ console.log(line);
207
+ }
208
+ process.exit(0);
209
+ }
210
+ catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ const code = error?.code ? ` [${error.code}]` : "";
213
+ console.error(`Failed to fetch Loadmill Cloud usage: ${message}${code}`);
214
+ process.exit(1);
215
+ }
216
+ }
217
+ // Pure retroactive artifact fetch: `--cloud-session-id <sid> --artifacts <kinds>`
218
+ // with no --instructions. Reattaching to a terminal run and downloading its
219
+ // recordings/logs doesn't require driving anything, so it doesn't need the
220
+ // full headless-instructions machinery below (which requires --instructions
221
+ // for every cloud device-source run).
222
+ if (targetConfig.deviceSource === "loadmill-cloud" &&
223
+ targetConfig.cloudSessionId &&
224
+ Array.isArray(targetConfig.artifacts) &&
225
+ targetConfig.artifacts.length > 0 &&
226
+ !instructionsFile) {
227
+ const credentials = resolveLoadmillCloudCredentials(process.env);
228
+ // Share the exact same rendering the normal run command uses — install
229
+ // the progress listener before touching the adapter, since prepareSession
230
+ // reports poll/download progress unconditionally and would otherwise be
231
+ // silently dropped (no listener installed => no output for 10-20s).
232
+ const retroactiveStatusLine = createStatusLine();
233
+ setCloudProgressListener((event) => {
234
+ // The run may still be finishing up on the cloud device when this
235
+ // starts — only switch the umbrella label to "fetching" once the
236
+ // artifact list is actually in hand and downloads are starting.
237
+ if (event.kind === "note" && event.message === "Fetching artifacts") {
238
+ retroactiveStatusLine.rename("Fetching Loadmill Cloud artifacts");
239
+ }
240
+ retroactiveStatusLine.handleEvent(event);
241
+ });
242
+ try {
243
+ retroactiveStatusLine.start("Waiting for Loadmill Cloud artifacts");
244
+ await connectToDevice({
245
+ source: "loadmill-cloud",
246
+ providerId: "loadmill-cloud",
247
+ platform: targetConfig.platform,
248
+ deviceName: targetConfig.deviceName,
249
+ osVersion: targetConfig.osVersion,
250
+ appRef: targetConfig.appPath,
251
+ credentials,
252
+ cloudSessionId: targetConfig.cloudSessionId,
253
+ artifacts: targetConfig.artifacts,
254
+ }, null, { log: () => { } });
255
+ retroactiveStatusLine.stop();
256
+ // A resumable (non-terminal) session reconnected successfully instead of
257
+ // exiting via the terminal/artifacts branch above — there's nothing to
258
+ // drive without --instructions, so release the slot and say so.
259
+ await loadmillCloudAdapter.releaseSession(credentials, { waitForCompletion: false });
260
+ console.error(`Loadmill Cloud session ${targetConfig.cloudSessionId} is not terminal yet; add --instructions to drive it, or wait until it finishes and re-run this same command to fetch artifacts.`);
261
+ process.exit(1);
262
+ }
263
+ catch (error) {
264
+ retroactiveStatusLine.stop();
265
+ if (error?.artifactsOnlyExit) {
266
+ console.log(error.message);
267
+ console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
268
+ process.exit(0);
269
+ }
270
+ const message = error instanceof Error ? error.message : String(error);
271
+ console.error(`Loadmill Cloud artifact fetch failed: ${message}`);
272
+ process.exit(1);
273
+ }
274
+ }
176
275
  if (targetConfig.listDevices) {
177
276
  const credentials = resolveAwsDeviceFarmCredentials(process.env);
178
277
  const adapter = getCloudProviderAdapter("aws-device-farm");
@@ -188,12 +287,15 @@ async function main() {
188
287
  const isBrowserTarget = targetConfig.target === "web";
189
288
  const isLambdaTestTarget = targetConfig.deviceSource === "lambdatest";
190
289
  const isAwsDeviceFarmTarget = targetConfig.deviceSource === "aws-device-farm";
191
- const isCloudTarget = isLambdaTestTarget || isAwsDeviceFarmTarget;
290
+ const isLoadmillCloudTarget = targetConfig.deviceSource === "loadmill-cloud";
291
+ const isCloudTarget = isLambdaTestTarget || isAwsDeviceFarmTarget || isLoadmillCloudTarget;
192
292
  let lambdaTestSessionConnected = false;
193
293
  let lambdaTestSessionUrl = null;
194
294
  let lambdaTestCredentials = null;
195
295
  let awsSessionConnected = false;
196
296
  let awsDeviceFarmStatusLine = null;
297
+ let loadmillCloudSessionConnected = false;
298
+ let loadmillCloudStatusLine = null;
197
299
  const headlessDebug = createHeadlessDebugArtifacts({
198
300
  cwd: process.cwd(),
199
301
  enabled: isHeadlessInstructionsRun && debugMode
@@ -407,6 +509,94 @@ async function main() {
407
509
  console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
408
510
  console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
409
511
  }
512
+ else if (isLoadmillCloudTarget) {
513
+ const loadmillCloudCredentials = resolveLoadmillCloudCredentials(process.env);
514
+ loadmillCloudStatusLine = createStatusLine();
515
+ const statusLine = loadmillCloudStatusLine;
516
+ setCloudProgressListener((event) => statusLine.handleEvent(event));
517
+ const provisioningAbortController = new AbortController();
518
+ let loadmillCloudSigintHandled = false;
519
+ let loadmillCloudInterruptRequested = false;
520
+ process.once("SIGINT", async () => {
521
+ if (loadmillCloudSigintHandled)
522
+ return;
523
+ loadmillCloudSigintHandled = true;
524
+ loadmillCloudInterruptRequested = true;
525
+ console.log("\nStopping Loadmill Cloud session...");
526
+ provisioningAbortController.abort(new Error("Stopped by user"));
527
+ if (!loadmillCloudSessionConnected) {
528
+ process.exitCode = 130;
529
+ return;
530
+ }
531
+ await disconnectDevice({ waitForCompletion: false });
532
+ loadmillCloudAdapter.releaseActiveSession();
533
+ process.exit(130);
534
+ });
535
+ try {
536
+ statusLine.start("Starting Loadmill Cloud session");
537
+ targetId = await connectToDevice({
538
+ source: "loadmill-cloud",
539
+ providerId: "loadmill-cloud",
540
+ platform: targetConfig.platform,
541
+ deviceName: targetConfig.deviceName,
542
+ osVersion: targetConfig.osVersion,
543
+ appRef: targetConfig.appPath,
544
+ credentials: loadmillCloudCredentials,
545
+ sessionName: `droid-cua-cli-${new Date().toISOString()}`,
546
+ cloudSessionId: targetConfig.cloudSessionId,
547
+ artifacts: targetConfig.artifacts,
548
+ signal: provisioningAbortController.signal,
549
+ }, null,
550
+ // These are one-off descriptive messages (not the name of the
551
+ // in-progress step), so they must render as persistent ℹ lines —
552
+ // never painted into the volatile detail slot, where a stray message
553
+ // like "Connected to ..." would linger after its moment has passed
554
+ // and contradict an already-closed "✔ <phase>" line above it.
555
+ { log: (message) => statusLine.handleEvent({ kind: "note", message }) });
556
+ loadmillCloudSessionConnected = true;
557
+ if (loadmillCloudInterruptRequested) {
558
+ try {
559
+ await disconnectDevice({ waitForCompletion: false });
560
+ loadmillCloudAdapter.releaseActiveSession();
561
+ }
562
+ finally {
563
+ statusLine.stop();
564
+ loadmillCloudSessionConnected = false;
565
+ process.exitCode = 130;
566
+ }
567
+ return;
568
+ }
569
+ deviceInfo = await getDeviceInfo(targetId);
570
+ statusLine.stop();
571
+ }
572
+ catch (error) {
573
+ statusLine.stop();
574
+ if (error?.artifactsOnlyExit) {
575
+ console.log(error.message);
576
+ process.exitCode = 0;
577
+ return;
578
+ }
579
+ if (loadmillCloudSessionConnected) {
580
+ await disconnectDevice({ waitForCompletion: false });
581
+ loadmillCloudAdapter.releaseActiveSession();
582
+ loadmillCloudSessionConnected = false;
583
+ }
584
+ if (loadmillCloudInterruptRequested) {
585
+ process.exitCode = 130;
586
+ return;
587
+ }
588
+ if (isLoadmillCloudUsageLimitError(error)) {
589
+ const suffix = error.code ? ` (${error.code})` : "";
590
+ console.error(`\n${error.message}${suffix}`);
591
+ process.exitCode = 1;
592
+ return;
593
+ }
594
+ throw error;
595
+ }
596
+ console.log(`Connected to Loadmill Cloud device "${targetConfig.deviceName}" (${targetId})`);
597
+ console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
598
+ console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
599
+ }
410
600
  else {
411
601
  targetId = await connectToDevice(avdName, platform);
412
602
  deviceInfo = await getDeviceInfo(targetId);
@@ -420,6 +610,7 @@ async function main() {
420
610
  // If --instructions provided, run in headless mode
421
611
  if (instructionsFile) {
422
612
  let exitCode = null;
613
+ let succeeded = false;
423
614
  try {
424
615
  const headlessConfig = preloadedHeadlessConfig;
425
616
  const appContext = preloadedAppContext;
@@ -477,6 +668,7 @@ async function main() {
477
668
  report: reportConfig
478
669
  });
479
670
  exitCode = result.exitCode;
671
+ succeeded = true;
480
672
  }
481
673
  finally {
482
674
  if (lambdaTestSessionConnected) {
@@ -505,6 +697,18 @@ async function main() {
505
697
  awsSessionConnected = false;
506
698
  console.log("AWS Device Farm session ended.");
507
699
  }
700
+ if (loadmillCloudSessionConnected) {
701
+ console.log("Ending Loadmill Cloud session...");
702
+ loadmillCloudStatusLine.start("Ending Loadmill Cloud session");
703
+ await disconnectDevice({ waitForCompletion: shouldWaitForLoadmillCloudCompletion(targetConfig) });
704
+ loadmillCloudAdapter.releaseActiveSession();
705
+ loadmillCloudStatusLine.stop();
706
+ loadmillCloudSessionConnected = false;
707
+ console.log("Loadmill Cloud session ended.");
708
+ if (succeeded) {
709
+ console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
710
+ }
711
+ }
508
712
  }
509
713
  if (exitCode !== null) {
510
714
  process.exit(exitCode);
@@ -1,14 +1,5 @@
1
1
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2
- /**
3
- * Heroku-style volatile status line for long-running cloud-provider steps.
4
- * On a TTY each progress update overwrites the previous one (spinner + step
5
- * name + elapsed + latest detail) and the whole line is cleared when the step
6
- * finishes. On a non-TTY (CI, piped output) it degrades to plain log lines,
7
- * printed only when the detail changes. Feed it cloud progress events via
8
- * handleEvent: "phase" events time named sub-steps and "note" events keep
9
- * persistent info; both are printed (prefixed with `indent`) when the step
10
- * finishes.
11
- */
2
+ const HEARTBEAT_INTERVAL_MS = 10_000;
12
3
  export function createStatusLine({ stream = process.stderr, indent = "" } = {}) {
13
4
  const tty = stream.isTTY;
14
5
  let active = false;
@@ -18,7 +9,6 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
18
9
  let detail = "";
19
10
  let startedAt = 0;
20
11
  let lastPrintedDetail = "";
21
- let entries = [];
22
12
  let currentPhase = null;
23
13
  const render = () => {
24
14
  frame = (frame + 1) % SPINNER_FRAMES.length;
@@ -26,19 +16,45 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
26
16
  const line = `${SPINNER_FRAMES[frame]} ${stepName} (${seconds}s)${detail ? ` — ${detail}` : ""}`;
27
17
  stream.write(`\r\x1b[2K${line}`);
28
18
  };
19
+ const heartbeat = () => {
20
+ const seconds = Math.floor((Date.now() - startedAt) / 1000);
21
+ console.log(`… ${stepName}: still waiting (${seconds}s)${detail ? ` — ${detail}` : ""}`);
22
+ };
23
+ const printPersistent = (text) => {
24
+ if (tty) {
25
+ stream.write(`\r\x1b[2K${text}\n`);
26
+ }
27
+ else {
28
+ console.log(text);
29
+ }
30
+ };
29
31
  const closePhase = () => {
30
32
  if (currentPhase) {
31
- currentPhase.ms = Date.now() - currentPhase.startedAt;
33
+ if (currentPhase.closeText != null) {
34
+ printPersistent(`${indent}\x1b[32m✔\x1b[0m ${currentPhase.closeText}`);
35
+ }
36
+ else {
37
+ const ms = Date.now() - currentPhase.startedAt;
38
+ printPersistent(`${indent}\x1b[32m✔\x1b[0m ${currentPhase.name} \x1b[90m(${ms}ms)\x1b[0m`);
39
+ }
32
40
  currentPhase = null;
33
41
  }
34
42
  };
35
43
  return {
44
+ rename(name) {
45
+ if (!active) {
46
+ return;
47
+ }
48
+ stepName = name;
49
+ if (tty) {
50
+ render();
51
+ }
52
+ },
36
53
  start(name) {
37
54
  active = true;
38
55
  stepName = name;
39
56
  detail = "";
40
57
  lastPrintedDetail = "";
41
- entries = [];
42
58
  currentPhase = null;
43
59
  startedAt = Date.now();
44
60
  if (tty) {
@@ -46,6 +62,12 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
46
62
  // on its own row before this step starts drawing.
47
63
  timer = setInterval(render, 120);
48
64
  }
65
+ else {
66
+ timer = setInterval(heartbeat, HEARTBEAT_INTERVAL_MS);
67
+ if (typeof timer.unref === "function") {
68
+ timer.unref();
69
+ }
70
+ }
49
71
  },
50
72
  update(message) {
51
73
  if (!active) {
@@ -66,11 +88,30 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
66
88
  }
67
89
  if (event.kind === "phase") {
68
90
  closePhase();
69
- currentPhase = { kind: "phase", name: event.message, startedAt: Date.now(), ms: 0 };
70
- entries.push(currentPhase);
91
+ currentPhase = { name: event.message, startedAt: Date.now(), closeText: null };
71
92
  }
72
93
  else if (event.kind === "note") {
73
- entries.push({ kind: "note", text: event.message });
94
+ printPersistent(`${indent}\x1b[34mℹ\x1b[0m ${event.message}`);
95
+ return;
96
+ }
97
+ else if (event.kind === "phase-close-text") {
98
+ if (currentPhase) {
99
+ currentPhase.closeText = event.message;
100
+ }
101
+ return;
102
+ }
103
+ else if (event.kind === "phase-flush") {
104
+ closePhase();
105
+ return;
106
+ }
107
+ else if (event.kind === "print") {
108
+ // Closes any still-open phase first (flushing its "✔ ..." line)
109
+ // before printing the caller-supplied text verbatim, so a final
110
+ // summary can never race ahead of / glue onto the last phase's
111
+ // close line or the live spinner.
112
+ closePhase();
113
+ printPersistent(event.message);
114
+ return;
74
115
  }
75
116
  this.update(event.message);
76
117
  },
@@ -80,19 +121,10 @@ export function createStatusLine({ stream = process.stderr, indent = "" } = {})
80
121
  clearInterval(timer);
81
122
  timer = null;
82
123
  }
124
+ closePhase();
83
125
  if (tty) {
84
126
  stream.write("\r\x1b[2K");
85
127
  }
86
- closePhase();
87
- for (const entry of entries) {
88
- if (entry.kind === "phase") {
89
- stream.write(`${indent}\x1b[32m✔\x1b[0m ${entry.name} \x1b[90m(${entry.ms}ms)\x1b[0m\n`);
90
- }
91
- else {
92
- stream.write(`${indent}\x1b[34mℹ\x1b[0m ${entry.text}\n`);
93
- }
94
- }
95
- entries = [];
96
128
  }
97
129
  };
98
130
  }
@@ -166,7 +166,6 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
166
166
  console.log(`Debug logging enabled: ${currentLogFilePath}`);
167
167
  }
168
168
  console.log(`\nRunning test from: ${testPath}\n`);
169
- console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
170
169
  const appContextText = appContext.text;
171
170
  emitCliAppContextDebug(runId, appContext, appContextText);
172
171
  session.updateResponseId(undefined);
@@ -40,6 +40,7 @@ function normalizeBrowser(value) {
40
40
  const CLOUD_SOURCE_DISPLAY_NAMES = {
41
41
  lambdatest: "LambdaTest",
42
42
  "aws-device-farm": "AWS Device Farm",
43
+ "loadmill-cloud": "Loadmill Cloud",
43
44
  };
44
45
  function isCloudDeviceSource(deviceSource) {
45
46
  return Object.hasOwn(CLOUD_SOURCE_DISPLAY_NAMES, deviceSource);
@@ -49,13 +50,13 @@ function normalizeDeviceSource(value) {
49
50
  return "local";
50
51
  }
51
52
  if (typeof value !== "string") {
52
- throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm.");
53
+ throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm, loadmill-cloud.");
53
54
  }
54
55
  const normalized = value.trim().toLowerCase();
55
56
  if (normalized === "local" || isCloudDeviceSource(normalized)) {
56
57
  return normalized;
57
58
  }
58
- throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm.");
59
+ throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm, loadmill-cloud.");
59
60
  }
60
61
  function normalizeMobilePlatform(value, label) {
61
62
  if (!hasNonEmptyString(value)) {
@@ -79,11 +80,35 @@ function readNonEmptyCliValue(value) {
79
80
  }
80
81
  return null;
81
82
  }
83
+ const ARTIFACT_KINDS = ["video", "deviceLog", "appiumLog"];
84
+ function normalizeArtifactKinds(value) {
85
+ if (value == null || value === "") {
86
+ return null;
87
+ }
88
+ if (typeof value !== "string") {
89
+ throw new Error(`--artifacts must be a comma-separated list of: ${ARTIFACT_KINDS.join(", ")}, or all.`);
90
+ }
91
+ const raw = value.trim();
92
+ if (!raw) {
93
+ return null;
94
+ }
95
+ if (raw.toLowerCase() === "all") {
96
+ return [...ARTIFACT_KINDS];
97
+ }
98
+ const kinds = [...new Set(raw.split(",").map((kind) => kind.trim()).filter(Boolean))];
99
+ const invalid = kinds.filter((kind) => !ARTIFACT_KINDS.includes(kind));
100
+ if (invalid.length > 0) {
101
+ throw new Error(`--artifacts has invalid kind(s): ${invalid.join(", ")}. Must be one of: ${ARTIFACT_KINDS.join(", ")}, or all.`);
102
+ }
103
+ return kinds;
104
+ }
82
105
  function assertNoCloudFlags(args) {
83
106
  const cloudFlags = [
84
107
  ["device-name", "--device-name"],
85
108
  ["os-version", "--os-version"],
86
109
  ["app", "--app"],
110
+ ["cloud-session-id", "--cloud-session-id"],
111
+ ["artifacts", "--artifacts"],
87
112
  ];
88
113
  for (const [key, label] of cloudFlags) {
89
114
  if (args[key] != null) {
@@ -112,12 +137,26 @@ function normalizeCloudAppPath(value, platform, deviceSource) {
112
137
  }
113
138
  return appPath;
114
139
  }
140
+ // Loadmill Cloud end-of-run disconnect must wait for the run to reach a
141
+ // terminal status (and then poll/download artifacts) whenever --artifacts
142
+ // was requested; otherwise the CLI exits immediately after "stop run" and
143
+ // silently drops the artifact fetch entirely.
144
+ export function shouldWaitForLoadmillCloudCompletion(targetConfig) {
145
+ return Array.isArray(targetConfig?.artifacts) && targetConfig.artifacts.length > 0;
146
+ }
115
147
  export function resolveHeadlessTarget(args = {}) {
116
148
  const target = normalizeTarget(args.target);
117
149
  const sessionMode = normalizeSessionMode(args["session-mode"]);
118
150
  const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
119
151
  const platform = normalizeMobilePlatform(args.platform, "--platform");
120
- const deviceSource = normalizeDeviceSource(args["device-source"]);
152
+ // The mtc_ prefix on --cloud-session-id is loadmill-cloud's; when no
153
+ // --device-source was given, default to it so a bare retroactive-fetch
154
+ // command (e.g. `droid-cua --cloud-session-id mtc_... --artifacts all`)
155
+ // doesn't need --device-source spelled out. An explicit --device-source
156
+ // (cloud or not) is left untouched and still validated normally.
157
+ const cloudSessionIdGiven = readNonEmptyCliValue(args["cloud-session-id"]) != null;
158
+ const deviceSourceGiven = hasNonEmptyString(args["device-source"]);
159
+ const deviceSource = normalizeDeviceSource(!deviceSourceGiven && cloudSessionIdGiven ? "loadmill-cloud" : args["device-source"]);
121
160
  if (args["list-devices"] != null && deviceSource !== "aws-device-farm") {
122
161
  throw new Error("--list-devices can only be used with --device-source aws-device-farm.");
123
162
  }
@@ -139,6 +178,8 @@ export function resolveHeadlessTarget(args = {}) {
139
178
  deviceName: null,
140
179
  osVersion: null,
141
180
  appPath: null,
181
+ cloudSessionId: null,
182
+ artifacts: null,
142
183
  listDevices: false,
143
184
  };
144
185
  }
@@ -160,6 +201,8 @@ export function resolveHeadlessTarget(args = {}) {
160
201
  deviceName: null,
161
202
  osVersion: null,
162
203
  appPath: null,
204
+ cloudSessionId: null,
205
+ artifacts: null,
163
206
  listDevices: true,
164
207
  };
165
208
  }
@@ -169,20 +212,29 @@ export function resolveHeadlessTarget(args = {}) {
169
212
  if (isCloudDeviceSource(deviceSource)) {
170
213
  const deviceName = hasNonEmptyString(args["device-name"]) ? args["device-name"].trim() : null;
171
214
  const osVersion = readNonEmptyCliValue(args["os-version"]);
215
+ const cloudSessionId = readNonEmptyCliValue(args["cloud-session-id"]);
216
+ const isReconnect = deviceSource === "loadmill-cloud" && Boolean(cloudSessionId);
172
217
  const appPath = normalizeCloudAppPath(args.app, platform, deviceSource);
218
+ const artifacts = normalizeArtifactKinds(args.artifacts);
219
+ if (artifacts && deviceSource !== "loadmill-cloud") {
220
+ throw new Error("--artifacts can only be used with --device-source loadmill-cloud.");
221
+ }
173
222
  if (avdName) {
174
223
  throw new Error(`--avd cannot be used with --device-source ${deviceSource}.`);
175
224
  }
176
- if (!platform) {
225
+ // A --cloud-session-id reconnect (including a pure retroactive artifact
226
+ // fetch) doesn't need --platform/--device-name/--os-version — those only
227
+ // describe a fresh device request. See the --app skip below (same reasoning).
228
+ if (!platform && !isReconnect) {
177
229
  throw new Error(`--platform is required with --device-source ${deviceSource}.`);
178
230
  }
179
- if (!deviceName) {
231
+ if (!deviceName && !isReconnect) {
180
232
  throw new Error(`--device-name is required with --device-source ${deviceSource}.`);
181
233
  }
182
- if (!osVersion) {
234
+ if (!osVersion && !isReconnect) {
183
235
  throw new Error(`--os-version is required with --device-source ${deviceSource}.`);
184
236
  }
185
- if (!appPath) {
237
+ if (!appPath && !isReconnect) {
186
238
  throw new Error(`--app is required with --device-source ${deviceSource}.`);
187
239
  }
188
240
  return {
@@ -195,6 +247,8 @@ export function resolveHeadlessTarget(args = {}) {
195
247
  deviceName,
196
248
  osVersion,
197
249
  appPath,
250
+ cloudSessionId,
251
+ artifacts,
198
252
  listDevices: false,
199
253
  };
200
254
  }
@@ -208,6 +262,8 @@ export function resolveHeadlessTarget(args = {}) {
208
262
  deviceName: null,
209
263
  osVersion: null,
210
264
  appPath: null,
265
+ cloudSessionId: null,
266
+ artifacts: null,
211
267
  listDevices: false,
212
268
  };
213
269
  }
@@ -0,0 +1,11 @@
1
+ import { createLoadmillApiClient } from "../integrations/loadmill/client.js";
2
+ export function resolveLoadmillCloudCredentials(env = process.env) {
3
+ const bearerToken = createLoadmillApiClient({
4
+ token: env.LOADMILL_API_TOKEN,
5
+ baseUrl: env.LOADMILL_BASE_URL,
6
+ }).getApiToken();
7
+ if (!bearerToken) {
8
+ throw new Error("LOADMILL_API_TOKEN environment variable is not set. It is required for --device-source loadmill-cloud.");
9
+ }
10
+ return { bearerToken, baseUrl: env.LOADMILL_BASE_URL || null };
11
+ }
@@ -0,0 +1,18 @@
1
+ const NO_USAGE_MESSAGE = "No usage quota configured for your team.";
2
+ function formatMeterLine(meter) {
3
+ const period = meter?.period ?? "unknown";
4
+ const minutes = meter?.minutes ?? {};
5
+ const used = minutes.used;
6
+ const limit = minutes.limit;
7
+ const left = minutes.left;
8
+ const percentageLeft = minutes.percentageLeft ?? meter?.percentageLeft;
9
+ const percentageLeftLabel = Number.isFinite(percentageLeft) ? `${Math.round(percentageLeft)}%` : "?%";
10
+ return `Loadmill Cloud usage: ${used} of ${limit} minutes used (${left} left, ${percentageLeftLabel} remaining) [${period}]`;
11
+ }
12
+ export function formatCloudUsageReport(usage) {
13
+ const meters = Array.isArray(usage?.meters) ? usage.meters : [];
14
+ if (meters.length === 0) {
15
+ return [NO_USAGE_MESSAGE];
16
+ }
17
+ return meters.map(formatMeterLine);
18
+ }
@@ -26,6 +26,8 @@ export async function handleHelp(args, session, context) {
26
26
  addOutput({ type: 'info', text: ' --device-name <name> LambdaTest real device name' });
27
27
  addOutput({ type: 'info', text: ' --os-version <version> LambdaTest OS version' });
28
28
  addOutput({ type: 'info', text: ' --app <path> LambdaTest app package path (.apk or .ipa)' });
29
+ addOutput({ type: 'info', text: ' --artifacts <kinds> Loadmill Cloud: download video,deviceLog,appiumLog (or all) after the run' });
30
+ addOutput({ type: 'info', text: ' --cloud-session-id <id> Loadmill Cloud: reconnect/retroactive-fetch by mtc_ id (implies --device-source loadmill-cloud)' });
29
31
  addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
30
32
  addOutput({ type: 'info', text: ' --scenario <prompt> Generate and run scenario cases for headless instructions' });
31
33
  addOutput({ type: 'info', text: ' --test-data <file> Optional test-data.md file for --scenario' });
@@ -13,6 +13,13 @@ export const CUA_MODEL_OPTIONS = Object.freeze([
13
13
  name: "Loadmill - Smart",
14
14
  label: "Loadmill - Smart (recommended)",
15
15
  }),
16
+ Object.freeze({
17
+ id: "loadmill-pulse",
18
+ providerMode: "loadmill",
19
+ providerLabel: "Loadmill",
20
+ name: "Loadmill - Pulse",
21
+ label: "Loadmill - Pulse",
22
+ }),
16
23
  Object.freeze({
17
24
  id: "loadmill-beacon",
18
25
  providerMode: "loadmill",