@loadmill/droid-cua 2.25.0 → 2.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +13 -5
  2. package/build/index.js +210 -2
  3. package/build/src/cli/cloud-status-line.js +58 -26
  4. package/build/src/cli/headless-execution-config.js +11 -0
  5. package/build/src/cli/headless-report.js +105 -15
  6. package/build/src/cli/headless-runner.js +125 -24
  7. package/build/src/cli/headless-target.js +63 -7
  8. package/build/src/cli/loadmill-cloud-credentials.js +11 -0
  9. package/build/src/cli/loadmill-cloud-usage.js +18 -0
  10. package/build/src/commands/help.js +5 -0
  11. package/build/src/core/cua-models.js +7 -0
  12. package/build/src/core/droid-cua-run-usage.js +8 -2
  13. package/build/src/core/execution-engine.js +6 -2
  14. package/build/src/core/report/render-html-report.js +69 -12
  15. package/build/src/core/runtime-config.js +10 -4
  16. package/build/src/device/assertions.js +3 -0
  17. package/build/src/device/cloud/aws-device-farm/upload-cache.js +1 -1
  18. package/build/src/device/cloud/connection.js +18 -6
  19. package/build/src/device/cloud/loadmill-cloud/adapter.js +537 -0
  20. package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +191 -0
  21. package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +423 -0
  22. package/build/src/device/cloud/loadmill-cloud/errors.js +16 -0
  23. package/build/src/device/cloud/loadmill-cloud/format-bytes.js +18 -0
  24. package/build/src/device/cloud/loadmill-cloud/timing.js +14 -0
  25. package/build/src/device/cloud/progress.js +29 -0
  26. package/build/src/device/cloud/registry.js +2 -1
  27. package/build/src/device/connection.js +3 -0
  28. package/build/src/device/openai.js +1 -0
  29. package/build/src/integrations/loadmill/client.js +14 -0
  30. package/build/src/integrations/loadmill/report-upload-manager.js +221 -0
  31. package/build/src/integrations/loadmill/run-reporter.js +6 -2
  32. package/build/src/modes/execution-mode.js +2 -1
  33. package/build/src/prompts/design.js +9 -1
  34. package/build/src/prompts/execution.js +10 -7
  35. package/build/src/utils/console-output.js +3 -0
  36. package/package.json +1 -1
  37. package/skill/droid-cua/references/running-and-healing.md +3 -3
@@ -8,20 +8,35 @@ import { mkdir, writeFile } from "node:fs/promises";
8
8
  import { renderHtmlReport } from "../core/report/render-html-report.js";
9
9
  import { buildReportFileName } from "../core/report/report-file-name.js";
10
10
  import { normalizeEvents, deriveRunStatus, computeSuccessRate, makeFailureOrError, buildRunFinishedPayload, assembleRunReportModel, } from "../core/report/run-report-model.js";
11
+ import { droidReportScreenshotUrl } from "../integrations/loadmill/report-upload-manager.js";
11
12
  // Captures the emitted events into `sink` and forwards them to the underlying output
12
13
  // function (so console behaviour is unchanged). Maps the engine's `type` to the canonical
13
14
  // `kind`; `stepId` is intentionally not collected (the shared normalizer drops it — see R2).
14
- export function createCapturingAddOutput(underlying, sink) {
15
+ export function createCapturingAddOutput(underlying, sink, { cloudSink = null, uploadManager = null, uploadSession = null } = {}) {
15
16
  return (item) => {
16
17
  if (item && typeof item === "object") {
17
- sink.push({
18
+ const event = {
18
19
  ts: typeof item.ts === "string" ? item.ts : null,
19
20
  kind: typeof item.type === "string" ? item.type : "info",
20
21
  text: typeof item.text === "string" ? item.text : "",
21
22
  eventType: item.eventType,
22
23
  actionType: item.actionType,
23
24
  payload: item.payload,
24
- });
25
+ };
26
+ sink?.push(event);
27
+ if (cloudSink) {
28
+ const payload = event.payload && typeof event.payload === "object"
29
+ ? { ...event.payload }
30
+ : event.payload;
31
+ if (typeof payload?.screenshotDataUrl === "string") {
32
+ const screenshotObjectName = uploadManager?.enqueueScreenshot(uploadSession, payload.screenshotDataUrl);
33
+ delete payload.screenshotDataUrl;
34
+ if (screenshotObjectName) {
35
+ payload.screenshotObjectName = screenshotObjectName;
36
+ }
37
+ }
38
+ cloudSink.push({ ...event, payload });
39
+ }
25
40
  }
26
41
  return underlying(item);
27
42
  };
@@ -43,7 +58,7 @@ function deriveCounts(success, error, events) {
43
58
  // Builds a RunReportModel from captured CLI events plus the run's final stats. This is the
44
59
  // CLI-acquisition adapter: it knows how to capture live events + run stats, then delegates
45
60
  // ALL model assembly to the shared run-report-model.js (same code path as the XML/Desktop
46
- // parser). A `run_finished` event is synthesized because the CLI output stream omits one.
61
+ // parser). A `run_finished` event is synthesized when the captured output stream omits one.
47
62
  export function buildCliRunReportModel({ suiteName, testName, startedAtIso, durationMs, success, error, instructionsTotal, stats = {}, events, }) {
48
63
  const warnings = [];
49
64
  const normalizedEvents = normalizeEvents(events, { fallbackTs: startedAtIso ?? null, warnings });
@@ -58,12 +73,15 @@ export function buildCliRunReportModel({ suiteName, testName, startedAtIso, dura
58
73
  assertionsFailed: stats.assertionsFailed,
59
74
  retries: stats.retryCount,
60
75
  });
61
- // The synthetic run_finished is already in canonical shape; appended as-is (ts stays null,
62
- // matching the historical CLI output) rather than through the normalizer.
63
- const allEvents = [
64
- ...normalizedEvents,
65
- { ts: null, kind: "system", text: "Execution finished", eventType: "run_finished", payload: summaryPayload },
66
- ];
76
+ const hasRunFinished = normalizedEvents.some((event) => event.eventType === "run_finished");
77
+ // The synthetic run_finished is already in canonical shape; append it as-is (ts stays
78
+ // null, matching the historical CLI output) only when the producer did not emit one.
79
+ const allEvents = hasRunFinished
80
+ ? normalizedEvents
81
+ : [
82
+ ...normalizedEvents,
83
+ { ts: null, kind: "system", text: "Execution finished", eventType: "run_finished", payload: summaryPayload },
84
+ ];
67
85
  const { failures, errors, stopped } = deriveCounts(success, error, allEvents);
68
86
  const status = deriveRunStatus({ tests: 1, failures, errors, stopped });
69
87
  const successRate = computeSuccessRate({ tests: 1, failures, errors, status });
@@ -97,17 +115,89 @@ export function resolveReportOutputPath({ reportPath, testName, startedAtIso, cw
97
115
  }
98
116
  return path.join(resolved, fileName);
99
117
  }
100
- // Renders and writes the HTML report for one run. Returns the absolute output path.
101
- export async function writeCliRunReport(modelInput, { reportPath, cwd = process.cwd(), isBatch = false } = {}) {
118
+ export function createCliRunReport(modelInput) {
102
119
  const model = buildCliRunReportModel(modelInput);
120
+ return {
121
+ model,
122
+ html: renderHtmlReport(model),
123
+ fileName: buildReportFileName({
124
+ testName: modelInput.testName,
125
+ suiteName: modelInput.suiteName,
126
+ timestamp: modelInput.startedAtIso,
127
+ }),
128
+ };
129
+ }
130
+ export function createCliAggregateRunReport({ suiteName, testName, startedAtIso, durationMs, childModels, }) {
131
+ const childTests = childModels.map((model) => ({
132
+ testName: model.testName ?? "Test",
133
+ durationSeconds: model.durationSeconds,
134
+ status: model.status,
135
+ successRate: model.successRate,
136
+ failureOrError: model.failureOrError,
137
+ events: model.events,
138
+ warnings: model.warnings,
139
+ }));
140
+ const failures = childTests.filter((child) => child.status === "fail").length;
141
+ const errors = childTests.filter((child) => child.status === "error").length;
142
+ const skipped = childTests.filter((child) => child.status === "skipped").length;
143
+ const stopped = childTests.some((child) => child.status === "stopped");
144
+ const status = deriveRunStatus({
145
+ tests: childTests.length,
146
+ failures,
147
+ errors,
148
+ skipped,
149
+ stopped,
150
+ });
151
+ const model = assembleRunReportModel({
152
+ suiteName,
153
+ testName,
154
+ timestamp: startedAtIso,
155
+ durationSeconds: Number.isFinite(durationMs) ? durationMs / 1000 : null,
156
+ status,
157
+ testCases: childTests.length,
158
+ successRate: computeSuccessRate({
159
+ tests: childTests.length,
160
+ failures,
161
+ errors,
162
+ skipped,
163
+ status,
164
+ }),
165
+ events: [],
166
+ childTests,
167
+ warnings: [],
168
+ });
169
+ return { model, html: renderHtmlReport(model) };
170
+ }
171
+ export function renderCliCloudReport(model, savedRunId) {
172
+ const cloudModel = structuredClone(model);
173
+ const replaceScreenshots = (events = []) => {
174
+ for (const event of events) {
175
+ const name = event.payload?.screenshotObjectName;
176
+ if (typeof name === "string") {
177
+ event.payload.screenshotUrl = droidReportScreenshotUrl(savedRunId, name);
178
+ delete event.payload.screenshotObjectName;
179
+ }
180
+ }
181
+ };
182
+ replaceScreenshots(cloudModel.events);
183
+ for (const child of cloudModel.childTests ?? []) {
184
+ replaceScreenshots(child.events);
185
+ }
186
+ return renderHtmlReport(cloudModel, { includeScripts: false });
187
+ }
188
+ export async function writeCliRunReportArtifact(artifact, { reportPath, cwd = process.cwd(), isBatch = false } = {}) {
103
189
  const outputPath = resolveReportOutputPath({
104
190
  reportPath,
105
- testName: modelInput.testName,
106
- startedAtIso: modelInput.startedAtIso,
191
+ testName: artifact.model.testName,
192
+ startedAtIso: artifact.model.timestamp,
107
193
  cwd,
108
194
  isBatch,
109
195
  });
110
196
  await mkdir(path.dirname(outputPath), { recursive: true });
111
- await writeFile(outputPath, renderHtmlReport(model), "utf-8");
197
+ await writeFile(outputPath, artifact.html, "utf-8");
112
198
  return outputPath;
113
199
  }
200
+ // Renders and writes the HTML report for one run. Returns the absolute output path.
201
+ export async function writeCliRunReport(modelInput, { reportPath, cwd = process.cwd(), isBatch = false } = {}) {
202
+ return await writeCliRunReportArtifact(createCliRunReport(modelInput), { reportPath, cwd, isBatch });
203
+ }
@@ -7,11 +7,12 @@ import { buildExecutionModePrompt } from "../core/prompts.js";
7
7
  import { addOpenAiResponseToDroidCuaRunUsage, createDroidCuaRunUsageTotals, estimateDroidCuaRunCostUsd, setDroidCuaRunUsageModel, } from "../core/droid-cua-run-usage.js";
8
8
  import { ExecutionMode } from "../modes/execution-mode.js";
9
9
  import { reportDroidRunCompletion } from "../integrations/loadmill/run-reporter.js";
10
+ import { droidReportUploadManager } from "../integrations/loadmill/report-upload-manager.js";
10
11
  import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-cua-segment.js";
11
12
  import { printCliOutput } from "../utils/console-output.js";
12
13
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
13
14
  import { loadHeadlessInstructions } from "./headless-instructions.js";
14
- import { createCapturingAddOutput, writeCliRunReport } from "./headless-report.js";
15
+ import { buildCliRunReportModel, createCapturingAddOutput, createCliAggregateRunReport, createCliRunReport, renderCliCloudReport, writeCliRunReportArtifact, } from "./headless-report.js";
15
16
  function sanitizePathSegment(value) {
16
17
  return String(value || "test")
17
18
  .replace(/\.dcua$/i, "")
@@ -24,6 +25,14 @@ function createHeadlessRunId(index = 0) {
24
25
  const suffix = index > 0 ? `-${index + 1}` : "";
25
26
  return `run-${Date.now()}${suffix}`;
26
27
  }
28
+ function logPendingScreenshotUploads(uploadManager, uploadSession, subject) {
29
+ console.log('Verifying no pending upload exists');
30
+ const pendingCount = uploadManager.getPendingCount?.(uploadSession) ?? 0;
31
+ if (pendingCount > 0) {
32
+ const uploads = pendingCount === 1 ? "upload" : "uploads";
33
+ console.log(`${subject} finished. Waiting for ${pendingCount} screenshot ${uploads} to finish...`);
34
+ }
35
+ }
27
36
  function getRecordScreenshotDirForTest(baseScreenshotDir, testName, isBatchRun) {
28
37
  if (!baseScreenshotDir || !isBatchRun) {
29
38
  return baseScreenshotDir;
@@ -122,7 +131,7 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
122
131
  }
123
132
  return engineOptions;
124
133
  }
125
- async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true }) {
134
+ async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
126
135
  let executionMode = null;
127
136
  let loadmillSessionUser = null;
128
137
  let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
@@ -142,10 +151,17 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
142
151
  ...buildSegmentUsageProperties(),
143
152
  });
144
153
  };
145
- const reportEnabled = Boolean(report?.enabled);
146
- const collectedEvents = [];
154
+ const localReportEnabled = Boolean(report?.enabled);
155
+ const cloudReportEnabled = Boolean(cloudUploadSession);
156
+ const reportEnabled = localReportEnabled || cloudReportEnabled;
157
+ const localEvents = [];
158
+ const cloudEvents = [];
147
159
  const addOutput = reportEnabled
148
- ? createCapturingAddOutput(printCliOutput, collectedEvents)
160
+ ? createCapturingAddOutput(printCliOutput, localReportEnabled ? localEvents : null, {
161
+ cloudSink: cloudReportEnabled ? cloudEvents : null,
162
+ uploadManager,
163
+ uploadSession: cloudUploadSession,
164
+ })
149
165
  : printCliOutput;
150
166
  try {
151
167
  try {
@@ -166,7 +182,6 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
166
182
  console.log(`Debug logging enabled: ${currentLogFilePath}`);
167
183
  }
168
184
  console.log(`\nRunning test from: ${testPath}\n`);
169
- console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
170
185
  const appContextText = appContext.text;
171
186
  emitCliAppContextDebug(runId, appContext, appContextText);
172
187
  session.updateResponseId(undefined);
@@ -176,6 +191,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
176
191
  testDataContext,
177
192
  availableSecretKeys: secrets?.availableSecretKeys || [],
178
193
  cuaModel: runtimeContext.config.cuaModel,
194
+ explainMode: runtimeContext.config.explainMode,
179
195
  });
180
196
  session.setSystemPrompt(executionPrompt);
181
197
  const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
@@ -227,8 +243,9 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
227
243
  durationMs,
228
244
  numOfSteps: instructions.length,
229
245
  });
246
+ let reportedRun = null;
230
247
  if (reportToLoadmill) {
231
- await reportDroidRunCompletion({
248
+ reportedRun = await reportDroidRunCompletion({
232
249
  loadmillClient: runtimeContext.loadmillClient,
233
250
  source: "ci",
234
251
  runId,
@@ -256,19 +273,19 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
256
273
  if (!result.success) {
257
274
  console.error(`\nTest failed: ${result.error}`);
258
275
  }
259
- if (reportEnabled) {
276
+ const reportInput = {
277
+ suiteName: path.basename(path.dirname(testPath)) || null,
278
+ testName,
279
+ startedAtIso: stats.startTime ? new Date(stats.startTime).toISOString() : null,
280
+ durationMs,
281
+ success: Boolean(result.success),
282
+ error: result.error ?? null,
283
+ instructionsTotal: instructions.length,
284
+ stats,
285
+ };
286
+ if (localReportEnabled) {
260
287
  try {
261
- const outputPath = await writeCliRunReport({
262
- suiteName: path.basename(path.dirname(testPath)) || null,
263
- testName,
264
- startedAtIso: stats.startTime ? new Date(stats.startTime).toISOString() : null,
265
- durationMs,
266
- success: Boolean(result.success),
267
- error: result.error ?? null,
268
- instructionsTotal: instructions.length,
269
- stats,
270
- events: collectedEvents,
271
- }, { reportPath: report?.path ?? null, isBatch: isBatchRun });
288
+ const outputPath = await writeCliRunReportArtifact(createCliRunReport({ ...reportInput, events: localEvents }), { reportPath: report?.path ?? null, isBatch: isBatchRun });
272
289
  console.log(`HTML report saved: ${outputPath}`);
273
290
  }
274
291
  catch (reportError) {
@@ -276,6 +293,16 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
276
293
  console.warn(`Warning: failed to write HTML report: ${message}`);
277
294
  }
278
295
  }
296
+ const cloudModel = cloudReportEnabled
297
+ ? buildCliRunReportModel({ ...reportInput, events: cloudEvents })
298
+ : null;
299
+ if (cloudModel && reportToLoadmill && reportedRun?.savedRunId) {
300
+ logPendingScreenshotUploads(uploadManager, cloudUploadSession, "Test");
301
+ await uploadManager.finish(cloudUploadSession, {
302
+ savedRunId: reportedRun.savedRunId,
303
+ html: renderCliCloudReport(cloudModel, reportedRun.savedRunId),
304
+ });
305
+ }
279
306
  return {
280
307
  success: Boolean(result.success),
281
308
  error: result.error ?? null,
@@ -285,7 +312,8 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
285
312
  actionsTotal: stats.actionCount ?? 0,
286
313
  assertionsPassed: stats.assertionsPassed ?? 0,
287
314
  assertionsFailed: stats.assertionsFailed ?? 0,
288
- retries: stats.retryCount ?? 0
315
+ retries: stats.retryCount ?? 0,
316
+ reportModel: cloudModel,
289
317
  };
290
318
  }
291
319
  catch (error) {
@@ -312,8 +340,9 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
312
340
  durationMs,
313
341
  numOfSteps: instructions.length,
314
342
  });
343
+ let reportedRun = null;
315
344
  if (reportToLoadmill) {
316
- await reportDroidRunCompletion({
345
+ reportedRun = await reportDroidRunCompletion({
317
346
  loadmillClient: runtimeContext.loadmillClient,
318
347
  source: "ci",
319
348
  runId,
@@ -338,11 +367,63 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
338
367
  onWarning: (warningMessage) => console.warn(`Warning: ${warningMessage}`),
339
368
  });
340
369
  }
370
+ const failedReportInput = {
371
+ suiteName: path.basename(path.dirname(testPath)) || null,
372
+ testName,
373
+ startedAtIso: stats.startTime ? new Date(stats.startTime).toISOString() : null,
374
+ durationMs,
375
+ success: false,
376
+ error: message,
377
+ instructionsTotal: instructions.length,
378
+ stats,
379
+ };
380
+ if (localReportEnabled) {
381
+ try {
382
+ const outputPath = await writeCliRunReportArtifact(createCliRunReport({ ...failedReportInput, events: localEvents }), { reportPath: report?.path ?? null, isBatch: isBatchRun });
383
+ console.log(`HTML report saved: ${outputPath}`);
384
+ }
385
+ catch (reportError) {
386
+ console.warn(`Warning: failed to write HTML report: ${reportError instanceof Error ? reportError.message : String(reportError)}`);
387
+ }
388
+ }
389
+ const cloudModel = cloudReportEnabled
390
+ ? buildCliRunReportModel({ ...failedReportInput, events: cloudEvents })
391
+ : null;
392
+ if (cloudModel && reportToLoadmill && reportedRun?.savedRunId) {
393
+ logPendingScreenshotUploads(uploadManager, cloudUploadSession, "Test");
394
+ await uploadManager.finish(cloudUploadSession, {
395
+ savedRunId: reportedRun.savedRunId,
396
+ html: renderCliCloudReport(cloudModel, reportedRun.savedRunId),
397
+ });
398
+ }
399
+ if (!reportToLoadmill) {
400
+ console.error(`\nTest failed: ${testName}: ${message}`);
401
+ return {
402
+ success: false,
403
+ error: message,
404
+ durationMs,
405
+ instructionsTotal: instructions.length,
406
+ instructionsCompleted: stats.instructionsCompleted ?? 0,
407
+ actionsTotal: stats.actionCount ?? 0,
408
+ assertionsPassed: stats.assertionsPassed ?? 0,
409
+ assertionsFailed: stats.assertionsFailed ?? 0,
410
+ retries: stats.retryCount ?? 0,
411
+ reportModel: cloudModel,
412
+ };
413
+ }
341
414
  throw error;
342
415
  }
343
416
  }
344
- export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, report = null, runHeadlessTestImpl = runHeadlessTest }) {
417
+ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, report = null, runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
345
418
  const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
419
+ const cloudReportEnabled = report?.cloudEnabled !== false &&
420
+ Boolean(runtimeContext.loadmillClient?.getApiToken?.());
421
+ const cloudUploadSession = cloudReportEnabled
422
+ ? uploadManager.startSession({
423
+ loadmillClient: runtimeContext.loadmillClient,
424
+ onWarning: (message) => console.warn(`Warning: ${message}`),
425
+ })
426
+ : null;
346
427
  if (instructionsTarget?.type === "scenario") {
347
428
  console.log(`\nRunning ${runs.length} scenario case${runs.length === 1 ? "" : "s"}\n`);
348
429
  }
@@ -360,6 +441,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
360
441
  assertionsFailed: 0,
361
442
  retries: 0
362
443
  };
444
+ const aggregateReportModels = [];
363
445
  for (const [index, run] of runs.entries()) {
364
446
  const runId = createHeadlessRunId(index);
365
447
  const testScreenshotDir = getRecordScreenshotDirForTest(screenshotDir, run.testName, isBatchRun);
@@ -390,7 +472,9 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
390
472
  isCloudTarget,
391
473
  report,
392
474
  isBatchRun,
393
- reportToLoadmill: !isBatchRun
475
+ reportToLoadmill: !isBatchRun,
476
+ cloudUploadSession,
477
+ uploadManager,
394
478
  });
395
479
  aggregateStats.durationMs += result.durationMs ?? 0;
396
480
  aggregateStats.instructionsTotal += result.instructionsTotal ?? 0;
@@ -399,6 +483,9 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
399
483
  aggregateStats.assertionsPassed += result.assertionsPassed ?? 0;
400
484
  aggregateStats.assertionsFailed += result.assertionsFailed ?? 0;
401
485
  aggregateStats.retries += result.retries ?? 0;
486
+ if (result.reportModel) {
487
+ aggregateReportModels.push(result.reportModel);
488
+ }
402
489
  if (!result.success) {
403
490
  failures += 1;
404
491
  }
@@ -423,7 +510,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
423
510
  if (isBatchRun) {
424
511
  const isScenario = instructionsTarget?.type === "scenario";
425
512
  const testName = isScenario ? "Scenario run" : path.basename(instructionsTarget.path);
426
- await reportDroidRunCompletion({
513
+ const reportedRun = await reportDroidRunCompletion({
427
514
  loadmillClient: runtimeContext.loadmillClient,
428
515
  source: "ci",
429
516
  runId: createHeadlessRunId(),
@@ -448,6 +535,20 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
448
535
  retries: aggregateStats.retries,
449
536
  onWarning: (message) => console.warn(`Warning: ${message}`),
450
537
  });
538
+ if (cloudUploadSession && reportedRun.savedRunId) {
539
+ const aggregateReport = createCliAggregateRunReport({
540
+ suiteName: isScenario ? path.basename(process.cwd()) : path.basename(instructionsTarget.path),
541
+ testName,
542
+ startedAtIso: new Date(batchStartedAt).toISOString(),
543
+ durationMs: Math.max(aggregateStats.durationMs, Date.now() - batchStartedAt),
544
+ childModels: aggregateReportModels,
545
+ });
546
+ logPendingScreenshotUploads(uploadManager, cloudUploadSession, "Run");
547
+ await uploadManager.finish(cloudUploadSession, {
548
+ savedRunId: reportedRun.savedRunId,
549
+ html: renderCliCloudReport(aggregateReport.model, reportedRun.savedRunId),
550
+ });
551
+ }
451
552
  }
452
553
  return {
453
554
  exitCode: failures === 0 ? 0 : 1,
@@ -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' });
@@ -44,6 +46,9 @@ export async function handleHelp(args, session, context) {
44
46
  addOutput({ type: 'info', text: ' --base-prompt-file Headless base prompt customization file' });
45
47
  addOutput({ type: 'info', text: ' --execution-prompt-file Headless execution prompt customization file' });
46
48
  addOutput({ type: 'info', text: ' --record Record screenshots during execution' });
49
+ addOutput({ type: 'info', text: ' --explain Explain non-obvious decisions and completion evidence' });
50
+ addOutput({ type: 'info', text: ' --report [path] Save a self-contained HTML report locally' });
51
+ addOutput({ type: 'info', text: ' --no-cloud-report Skip uploading the HTML report and screenshots to Loadmill' });
47
52
  addOutput({ type: 'info', text: ' --debug Enable structured JSONL debug artifacts' });
48
53
  addOutput({ type: 'info', text: '' });
49
54
  addOutput({ type: 'info', text: 'Interactive commands:' });
@@ -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",