@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.
- package/README.md +13 -5
- package/build/index.js +210 -2
- package/build/src/cli/cloud-status-line.js +58 -26
- package/build/src/cli/headless-execution-config.js +11 -0
- package/build/src/cli/headless-report.js +105 -15
- package/build/src/cli/headless-runner.js +125 -24
- 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 +5 -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 +6 -2
- package/build/src/core/report/render-html-report.js +69 -12
- package/build/src/core/runtime-config.js +10 -4
- package/build/src/device/assertions.js +3 -0
- 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/build/src/integrations/loadmill/client.js +14 -0
- package/build/src/integrations/loadmill/report-upload-manager.js +221 -0
- package/build/src/integrations/loadmill/run-reporter.js +6 -2
- package/build/src/modes/execution-mode.js +2 -1
- package/build/src/prompts/design.js +9 -1
- package/build/src/prompts/execution.js +10 -7
- package/build/src/utils/console-output.js +3 -0
- package/package.json +1 -1
- 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
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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:
|
|
106
|
-
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,
|
|
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,
|
|
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
|
|
146
|
-
const
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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' });
|
|
@@ -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",
|