@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 +8 -5
- package/build/index.js +206 -2
- package/build/src/cli/cloud-status-line.js +58 -26
- package/build/src/cli/headless-runner.js +0 -1
- package/build/src/cli/headless-target.js +63 -7
- package/build/src/cli/loadmill-cloud-credentials.js +11 -0
- package/build/src/cli/loadmill-cloud-usage.js +18 -0
- package/build/src/commands/help.js +2 -0
- package/build/src/core/cua-models.js +7 -0
- package/build/src/core/droid-cua-run-usage.js +8 -2
- package/build/src/core/execution-engine.js +1 -0
- package/build/src/core/runtime-config.js +8 -3
- package/build/src/device/cloud/aws-device-farm/upload-cache.js +1 -1
- package/build/src/device/cloud/connection.js +18 -6
- package/build/src/device/cloud/loadmill-cloud/adapter.js +537 -0
- package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +191 -0
- package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +423 -0
- package/build/src/device/cloud/loadmill-cloud/errors.js +16 -0
- package/build/src/device/cloud/loadmill-cloud/format-bytes.js +18 -0
- package/build/src/device/cloud/loadmill-cloud/timing.js +14 -0
- package/build/src/device/cloud/progress.js +29 -0
- package/build/src/device/cloud/registry.js +2 -1
- package/build/src/device/connection.js +3 -0
- package/build/src/device/openai.js +1 -0
- package/package.json +1 -1
- package/skill/droid-cua/references/running-and-healing.md +3 -3
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.
|
|
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
|
|
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-
|
|
260
|
-
`gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI
|
|
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
|
|
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.
|
|
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 = {
|
|
70
|
-
entries.push(currentPhase);
|
|
91
|
+
currentPhase = { name: event.message, startedAt: Date.now(), closeText: null };
|
|
71
92
|
}
|
|
72
93
|
else if (event.kind === "note") {
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|