@loadmill/droid-cua 2.16.0 → 2.18.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 +18 -0
- package/build/index.js +88 -1
- package/build/src/browser/playwright/actions.js +17 -8
- package/build/src/cli/headless-execution-config.js +21 -0
- package/build/src/cli/headless-report.js +113 -0
- package/build/src/cli/headless-runner.js +57 -11
- package/build/src/cli/scenario-planner.js +1 -0
- package/build/src/commands/help.js +4 -0
- package/build/src/commands/run.js +2 -1
- package/build/src/core/execution-engine.js +57 -2
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/report/generate-html-report.js +88 -0
- package/build/src/core/report/parse-run-report.js +222 -0
- package/build/src/core/report/render-html-report.js +317 -0
- package/build/src/core/report/report-file-name.js +31 -0
- package/build/src/core/report/run-log-model.js +144 -0
- package/build/src/core/report/run-report-model.js +143 -0
- package/build/src/core/scenario-planner.js +85 -0
- package/build/src/core/secrets.js +116 -0
- package/build/src/core/test-data-import.js +62 -0
- package/build/src/core/test-data.js +103 -0
- package/build/src/core/tool-handlers/close-current-tab.js +3 -3
- package/build/src/core/tool-handlers/navigate.js +3 -3
- package/build/src/core/tool-handlers/preview-click.js +6 -6
- package/build/src/core/tool-handlers/type-sensitive-text.js +62 -0
- package/build/src/core/tool-handlers/utils.js +3 -3
- package/build/src/device/android/actions.js +13 -8
- package/build/src/device/cloud/actions.js +17 -8
- package/build/src/device/ios/actions.js +17 -8
- package/build/src/device/openai.js +17 -0
- package/build/src/device/sensitive-action.js +61 -0
- package/build/src/modes/design-mode-ink.js +3 -1
- package/build/src/modes/design-mode.js +3 -1
- package/build/src/modes/execution-mode.js +1 -0
- package/build/src/prompts/design.js +4 -3
- package/build/src/prompts/execution.js +27 -1
- package/build/src/prompts/scenario-planner.js +48 -0
- package/build/src/prompts/test-data-import.js +49 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -206,7 +206,9 @@ Supported CLI options include:
|
|
|
206
206
|
- `--execution-prompt`
|
|
207
207
|
- `--base-prompt-file`
|
|
208
208
|
- `--execution-prompt-file`
|
|
209
|
+
- `--secrets`
|
|
209
210
|
- `--record`
|
|
211
|
+
- `--report`
|
|
210
212
|
- `--debug`
|
|
211
213
|
|
|
212
214
|
Config and precedence rules:
|
|
@@ -221,6 +223,22 @@ Config and precedence rules:
|
|
|
221
223
|
- `--base-prompt-file` and `--execution-prompt-file` override the corresponding prompt customizations from config.
|
|
222
224
|
- If both inline and file-based prompt overrides are provided, the inline prompt flags win.
|
|
223
225
|
|
|
226
|
+
Secret typing:
|
|
227
|
+
- Put runtime typing secrets in a project `.secrets` file using dotenv-style `KEY=value` lines.
|
|
228
|
+
- Desktop execution and design mode load `.secrets` from the selected project folder. Headless CLI loads `.secrets` from the current working directory.
|
|
229
|
+
- Headless CLI also accepts `--secrets KEY=value,OTHER=value`; these values override `.secrets` for that run.
|
|
230
|
+
- The agent sees only secret keys and may call `type_sensitive_text({ key })`. Logs and reports may include keys, but not resolved values.
|
|
231
|
+
- Screenshot masking is out of scope. Secret values also necessarily pass through ADB, Appium, or browser automation while being typed.
|
|
232
|
+
|
|
233
|
+
HTML report (`--report`):
|
|
234
|
+
- Writes a self-contained HTML report of the run — the same Run Details view the desktop app shows, with screenshots inlined — that you can share and open in any browser.
|
|
235
|
+
- With no value it saves `<test>--<date>--<time>.html` (e.g. `login--2026-06-03--16-09-20.html`) in the current directory; pass `--report <file>` to pick a filename or `--report <dir>` to pick a folder.
|
|
236
|
+
- In the desktop app the same report is available from a run's **Share → Download HTML report** button.
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua --report ./reports
|
|
240
|
+
```
|
|
241
|
+
|
|
224
242
|
LambdaTest CLI runs:
|
|
225
243
|
- Use `--device-source lambdatest` with `--platform`, `--device-name`, `--os-version`, `--app`, and `--instructions`.
|
|
226
244
|
- Set `LAMBDATEST_USERNAME` and `LAMBDATEST_ACCESS_KEY` in the environment.
|
package/build/index.js
CHANGED
|
@@ -13,6 +13,8 @@ import { createOpenAiExecutor } from "./src/device/openai.js";
|
|
|
13
13
|
import { logger } from "./src/utils/logger.js";
|
|
14
14
|
import { selectDevice } from "./src/cli/device-selector.js";
|
|
15
15
|
import { resolveExecutionAppContext } from "./src/core/app-context.js";
|
|
16
|
+
import { resolveExecutionTestData } from "./src/core/test-data.js";
|
|
17
|
+
import { getDefaultSecretsPath, resolveExecutionSecrets } from "./src/core/secrets.js";
|
|
16
18
|
import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
|
|
17
19
|
import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
|
|
18
20
|
import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
|
|
@@ -20,6 +22,7 @@ import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
|
|
|
20
22
|
import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
|
|
21
23
|
import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from "./src/cli/headless-instruction-target.js";
|
|
22
24
|
import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
|
|
25
|
+
import { planScenarioRuns } from "./src/cli/scenario-planner.js";
|
|
23
26
|
import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
|
|
24
27
|
import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
|
|
25
28
|
import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
|
|
@@ -33,6 +36,13 @@ const startupUrl = typeof args.url === "string" && args.url.trim().length > 0 ?
|
|
|
33
36
|
const instructionsFile = args.instructions || args.i || null;
|
|
34
37
|
const appContextPath = typeof args.context === "string" ? args.context : null;
|
|
35
38
|
const debugMode = args["debug"] || false;
|
|
39
|
+
// --report [path]: generate a self-contained HTML report at the end of the run.
|
|
40
|
+
// `--report` alone writes <test>-<stamp>.html in the cwd; `--report <path>` lets the
|
|
41
|
+
// user choose a file (single run) or directory (used for every run).
|
|
42
|
+
const reportConfig = {
|
|
43
|
+
enabled: args.report === true || (typeof args.report === "string" && args.report.trim().length > 0),
|
|
44
|
+
path: typeof args.report === "string" && args.report.trim().length > 0 ? args.report.trim() : null,
|
|
45
|
+
};
|
|
36
46
|
const screenshotDir = path.join("droid-cua-recording-" + Date.now());
|
|
37
47
|
if (recordScreenshots)
|
|
38
48
|
await mkdir(screenshotDir, { recursive: true });
|
|
@@ -67,6 +77,41 @@ function wait(ms) {
|
|
|
67
77
|
function isLambdaTestAppAutomationUrl(value) {
|
|
68
78
|
return typeof value === "string" && value.startsWith("https://appautomation.lambdatest.com/test?");
|
|
69
79
|
}
|
|
80
|
+
async function loadScenarioCandidateRuns(runs) {
|
|
81
|
+
return await Promise.all(runs.map(async (run) => ({
|
|
82
|
+
...run,
|
|
83
|
+
instructions: run.instructions ?? await loadHeadlessInstructions(run.testPath),
|
|
84
|
+
})));
|
|
85
|
+
}
|
|
86
|
+
function printScenarioCasePreview(cases) {
|
|
87
|
+
console.log(`\nScenario preview: ${cases.length} case${cases.length === 1 ? "" : "s"}\n`);
|
|
88
|
+
cases.forEach((entry, index) => {
|
|
89
|
+
console.log(`${index + 1}. ${entry.caseName}`);
|
|
90
|
+
});
|
|
91
|
+
console.log("");
|
|
92
|
+
}
|
|
93
|
+
function buildScenarioInstructionTarget(originalTarget, scenarioPrompt) {
|
|
94
|
+
return {
|
|
95
|
+
type: "scenario",
|
|
96
|
+
path: originalTarget?.path || null,
|
|
97
|
+
sourceType: originalTarget?.type || null,
|
|
98
|
+
scenarioPrompt,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function buildScenarioRuns({ candidateRuns, cases }) {
|
|
102
|
+
const runByTestName = new Map(candidateRuns.map((run) => [run.testName, run]));
|
|
103
|
+
return cases.map((entry) => {
|
|
104
|
+
const sourceRun = runByTestName.get(entry.testName);
|
|
105
|
+
return {
|
|
106
|
+
...sourceRun,
|
|
107
|
+
testName: entry.caseName,
|
|
108
|
+
sourceTestName: entry.testName,
|
|
109
|
+
scenarioCaseName: entry.caseName,
|
|
110
|
+
testDataContext: entry.testDataContext,
|
|
111
|
+
instructions: sourceRun.instructions,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
70
115
|
async function resolveLambdaTestDashboardUrl(credentials, sessionId, { attempts = 1, delayMs = 1000 } = {}) {
|
|
71
116
|
const adapter = getCloudProviderAdapter("lambdatest");
|
|
72
117
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
@@ -107,13 +152,25 @@ async function main() {
|
|
|
107
152
|
if (isLambdaTestTarget && !isHeadlessInstructionsRun) {
|
|
108
153
|
throw new Error("--device-source lambdatest is only supported with --instructions headless execution.");
|
|
109
154
|
}
|
|
155
|
+
if (!isHeadlessInstructionsRun && args.scenario != null) {
|
|
156
|
+
throw new Error("--scenario can only be used with --instructions headless execution.");
|
|
157
|
+
}
|
|
158
|
+
if (!isHeadlessInstructionsRun && args["test-data"] != null) {
|
|
159
|
+
throw new Error("--test-data can only be used with --instructions headless execution.");
|
|
160
|
+
}
|
|
110
161
|
let preloadedHeadlessConfig = null;
|
|
111
162
|
let preloadedHeadlessTarget = null;
|
|
112
163
|
let headlessInstructionRuns = [];
|
|
113
164
|
let preloadedAppContext = null;
|
|
165
|
+
let preloadedTestData = null;
|
|
166
|
+
let preloadedSecrets = null;
|
|
114
167
|
if (isHeadlessInstructionsRun) {
|
|
115
168
|
preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
|
|
116
169
|
preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
|
|
170
|
+
preloadedSecrets = await resolveExecutionSecrets({
|
|
171
|
+
secretsPath: getDefaultSecretsPath(process.cwd()),
|
|
172
|
+
cliSecrets: preloadedHeadlessConfig.secretsOverride,
|
|
173
|
+
});
|
|
117
174
|
if (preloadedHeadlessTarget.type === "directory") {
|
|
118
175
|
const files = await listHeadlessInstructionFiles(preloadedHeadlessTarget.path);
|
|
119
176
|
if (files.length === 0) {
|
|
@@ -139,6 +196,14 @@ async function main() {
|
|
|
139
196
|
if (preloadedAppContext.status === "failed") {
|
|
140
197
|
throw new Error(`Could not read app context file ${preloadedAppContext.contextPath}: ${preloadedAppContext.message}`);
|
|
141
198
|
}
|
|
199
|
+
if (preloadedHeadlessConfig.scenarioPrompt) {
|
|
200
|
+
preloadedTestData = await resolveExecutionTestData({
|
|
201
|
+
testDataPath: preloadedHeadlessConfig.testDataPath,
|
|
202
|
+
});
|
|
203
|
+
if (preloadedTestData.status === "failed") {
|
|
204
|
+
throw new Error(`Could not read test data file ${preloadedTestData.testDataPath}: ${preloadedTestData.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
142
207
|
}
|
|
143
208
|
// If no device specified, show interactive selection menu
|
|
144
209
|
if (!isBrowserTarget && !isLambdaTestTarget && !avdName && !platform) {
|
|
@@ -236,6 +301,26 @@ async function main() {
|
|
|
236
301
|
});
|
|
237
302
|
console.log(`Using AI provider: ${runtimeContext.config.llmProviderMode}`);
|
|
238
303
|
console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
|
|
304
|
+
if (headlessConfig.scenarioPrompt) {
|
|
305
|
+
const candidateRuns = await loadScenarioCandidateRuns(headlessInstructionRuns);
|
|
306
|
+
const scenarioCases = await planScenarioRuns({
|
|
307
|
+
client: runtimeContext.openAiClient,
|
|
308
|
+
runtimeConfig: runtimeContext.config,
|
|
309
|
+
scenarioPrompt: headlessConfig.scenarioPrompt,
|
|
310
|
+
tests: candidateRuns.map((run) => ({
|
|
311
|
+
testName: run.testName,
|
|
312
|
+
instructions: run.instructions,
|
|
313
|
+
})),
|
|
314
|
+
appContextText,
|
|
315
|
+
testDataText: preloadedTestData?.text || "",
|
|
316
|
+
});
|
|
317
|
+
printScenarioCasePreview(scenarioCases);
|
|
318
|
+
headlessInstructionRuns = buildScenarioRuns({
|
|
319
|
+
candidateRuns,
|
|
320
|
+
cases: scenarioCases,
|
|
321
|
+
});
|
|
322
|
+
preloadedHeadlessTarget = buildScenarioInstructionTarget(preloadedHeadlessTarget, headlessConfig.scenarioPrompt);
|
|
323
|
+
}
|
|
239
324
|
const result = await runHeadlessInstructionRuns({
|
|
240
325
|
runs: headlessInstructionRuns,
|
|
241
326
|
instructionsTarget: preloadedHeadlessTarget,
|
|
@@ -243,12 +328,14 @@ async function main() {
|
|
|
243
328
|
deviceInfo,
|
|
244
329
|
headlessConfig,
|
|
245
330
|
appContext,
|
|
331
|
+
secrets: preloadedSecrets,
|
|
246
332
|
runtimeContext,
|
|
247
333
|
headlessDebug,
|
|
248
334
|
recordScreenshots,
|
|
249
335
|
screenshotDir,
|
|
250
336
|
isBrowserTarget,
|
|
251
|
-
isLambdaTestTarget
|
|
337
|
+
isLambdaTestTarget,
|
|
338
|
+
report: reportConfig
|
|
252
339
|
});
|
|
253
340
|
exitCode = result.exitCode;
|
|
254
341
|
}
|
|
@@ -2,6 +2,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
2
2
|
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
3
3
|
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
4
4
|
import { buildActionEventMeta } from "../../device/action-event-meta.js";
|
|
5
|
+
import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSensitiveDebugFields, buildTypeActionPayload, buildTypeActionText, isSensitiveAction, } from "../../device/sensitive-action.js";
|
|
5
6
|
import { getActiveBrowserPage } from "./session-state.js";
|
|
6
7
|
const KEY_ALIASES = {
|
|
7
8
|
CTRL: "Control",
|
|
@@ -77,7 +78,8 @@ export async function handleModelAction(browserSessionId, action, scale = 1, con
|
|
|
77
78
|
y1: action?.y1,
|
|
78
79
|
x2: action?.x2,
|
|
79
80
|
y2: action?.y2,
|
|
80
|
-
text:
|
|
81
|
+
text: buildDebugTextField(action, truncateForDebug),
|
|
82
|
+
...buildSensitiveDebugFields(action),
|
|
81
83
|
keyCount: Array.isArray(action?.keys) ? action.keys.length : 0,
|
|
82
84
|
pathPoints: Array.isArray(action?.path) ? action.path.length : 0,
|
|
83
85
|
});
|
|
@@ -138,7 +140,11 @@ export async function handleModelAction(browserSessionId, action, scale = 1, con
|
|
|
138
140
|
}
|
|
139
141
|
case "type": {
|
|
140
142
|
const text = typeof action.text === "string" ? action.text : "";
|
|
141
|
-
addOutput({
|
|
143
|
+
addOutput({
|
|
144
|
+
type: "action",
|
|
145
|
+
text: buildTypeActionText(action, text),
|
|
146
|
+
...buildMeta(context, action, buildTypeActionPayload(action, text))
|
|
147
|
+
});
|
|
142
148
|
await page.keyboard.type(text);
|
|
143
149
|
break;
|
|
144
150
|
}
|
|
@@ -163,12 +169,14 @@ export async function handleModelAction(browserSessionId, action, scale = 1, con
|
|
|
163
169
|
default:
|
|
164
170
|
addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
|
|
165
171
|
}
|
|
172
|
+
return { success: true };
|
|
166
173
|
}
|
|
167
174
|
catch (error) {
|
|
175
|
+
const outputError = buildActionErrorOutput(action, error);
|
|
168
176
|
logger.error("Action execution error", {
|
|
169
|
-
action,
|
|
170
|
-
message:
|
|
171
|
-
stack: error.stack,
|
|
177
|
+
action: actionForErrorLog(action),
|
|
178
|
+
message: outputError.message,
|
|
179
|
+
stack: isSensitiveAction(action) ? undefined : error.stack,
|
|
172
180
|
});
|
|
173
181
|
emitDesktopDebug("browser.error", "device", {
|
|
174
182
|
runId: context?.runId,
|
|
@@ -179,21 +187,22 @@ export async function handleModelAction(browserSessionId, action, scale = 1, con
|
|
|
179
187
|
platform: "browser",
|
|
180
188
|
operation: "action.execute",
|
|
181
189
|
actionType: action?.type,
|
|
182
|
-
message:
|
|
190
|
+
message: outputError.message,
|
|
183
191
|
});
|
|
184
192
|
addOutput({
|
|
185
193
|
type: "error",
|
|
186
|
-
text:
|
|
194
|
+
text: outputError.text,
|
|
187
195
|
eventType: "error",
|
|
188
196
|
actionType: action?.type,
|
|
189
197
|
runId: context?.runId,
|
|
190
198
|
stepId: context?.stepId,
|
|
191
199
|
instructionIndex: context?.instructionIndex,
|
|
192
200
|
payload: {
|
|
193
|
-
message:
|
|
201
|
+
message: outputError.message,
|
|
194
202
|
platform: "browser",
|
|
195
203
|
},
|
|
196
204
|
});
|
|
197
205
|
addOutput({ type: "info", text: "Full error details have been logged to the debug log." });
|
|
206
|
+
return { success: false, error: outputError.message };
|
|
198
207
|
}
|
|
199
208
|
}
|
|
@@ -4,6 +4,7 @@ import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, MAX_CONTEXT_OPTIMIZATION_THRESH
|
|
|
4
4
|
import { AIM_ASSIST_MODES, DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
|
|
5
5
|
import { CUA_MODELS, DEFAULT_OPENAI_CUA_MODEL, isOpenAiCuaModel, isValidCuaModel, resolveCuaModel } from "../core/cua-models.js";
|
|
6
6
|
import { LLM_PROVIDER_MODES, isValidLlmProviderMode, resolveLlmProviderMode } from "../core/runtime-config.js";
|
|
7
|
+
import { parseSecretsOverrideString } from "../core/secrets.js";
|
|
7
8
|
function createEmptyPromptCustomizations() {
|
|
8
9
|
return {
|
|
9
10
|
basePromptInstructions: "",
|
|
@@ -172,6 +173,10 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
172
173
|
const cwd = typeof options.cwd === "string" ? options.cwd : process.cwd();
|
|
173
174
|
const configPath = typeof args.config === "string" ? args.config : null;
|
|
174
175
|
const explicitContextPath = typeof args.context === "string" ? path.resolve(cwd, args.context) : null;
|
|
176
|
+
const scenarioPrompt = typeof args.scenario === "string" ? args.scenario.trim() : null;
|
|
177
|
+
const hasScenarioFlag = args.scenario != null;
|
|
178
|
+
const testDataPath = typeof args["test-data"] === "string" ? path.resolve(cwd, args["test-data"]) : null;
|
|
179
|
+
const hasTestDataFlag = args["test-data"] != null;
|
|
175
180
|
const noContext = args["no-context"] === true || args.context === false;
|
|
176
181
|
const contextOptimizationEnabledByFlag = args["context-optimization"] === true;
|
|
177
182
|
const noContextOptimization = args["no-context-optimization"] === true || args["context-optimization"] === false;
|
|
@@ -180,12 +185,25 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
180
185
|
const executionPromptText = typeof args["execution-prompt"] === "string" ? args["execution-prompt"] : null;
|
|
181
186
|
const basePromptFilePath = typeof args["base-prompt-file"] === "string" ? path.resolve(cwd, args["base-prompt-file"]) : null;
|
|
182
187
|
const executionPromptFilePath = typeof args["execution-prompt-file"] === "string" ? path.resolve(cwd, args["execution-prompt-file"]) : null;
|
|
188
|
+
const secretsOverride = args.secrets == null ? null : args.secrets;
|
|
183
189
|
if (explicitContextPath && noContext) {
|
|
184
190
|
throw new Error("--context and --no-context cannot be used together.");
|
|
185
191
|
}
|
|
192
|
+
if (hasScenarioFlag && !scenarioPrompt) {
|
|
193
|
+
throw new Error("--scenario must be a non-empty string.");
|
|
194
|
+
}
|
|
195
|
+
if (hasTestDataFlag && !testDataPath) {
|
|
196
|
+
throw new Error("--test-data must be a non-empty path.");
|
|
197
|
+
}
|
|
198
|
+
if (testDataPath && !scenarioPrompt) {
|
|
199
|
+
throw new Error("--test-data can only be used with --scenario.");
|
|
200
|
+
}
|
|
186
201
|
if (contextOptimizationEnabledByFlag) {
|
|
187
202
|
throw new Error("--context-optimization is not supported; Context Optimization is enabled by default.");
|
|
188
203
|
}
|
|
204
|
+
if (secretsOverride != null) {
|
|
205
|
+
parseSecretsOverrideString(secretsOverride);
|
|
206
|
+
}
|
|
189
207
|
if (noContextOptimization && hasContextOptimizationThresholdFlag) {
|
|
190
208
|
throw new Error("--context-optimization-threshold and --no-context-optimization cannot be used together.");
|
|
191
209
|
}
|
|
@@ -202,6 +220,9 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
202
220
|
promptCustomizations,
|
|
203
221
|
appContextEnabled: fileConfig?.appContextEnabled ?? true,
|
|
204
222
|
appContextPath: fileConfig?.appContextPath || null,
|
|
223
|
+
scenarioPrompt,
|
|
224
|
+
testDataPath,
|
|
225
|
+
secretsOverride,
|
|
205
226
|
contextOptimizationEnabled: fileConfig?.contextOptimizationEnabled ?? true,
|
|
206
227
|
contextOptimizationThreshold: fileConfig?.contextOptimizationThreshold ?? DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD,
|
|
207
228
|
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// CLI-side HTML report generation for `--report`. Captures the run's emitted events
|
|
2
|
+
// (the same `addOutput` items the engine produces), synthesizes the run-summary event
|
|
3
|
+
// the CLI stream omits, and renders a self-contained HTML report via the shared core
|
|
4
|
+
// renderer (src/core/report). Screenshots are inlined as data URLs because the report
|
|
5
|
+
// run sets `includeScreenshotDataUrl: true` in the execution context.
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
8
|
+
import { renderHtmlReport } from "../core/report/render-html-report.js";
|
|
9
|
+
import { buildReportFileName } from "../core/report/report-file-name.js";
|
|
10
|
+
import { normalizeEvents, deriveRunStatus, computeSuccessRate, makeFailureOrError, buildRunFinishedPayload, assembleRunReportModel, } from "../core/report/run-report-model.js";
|
|
11
|
+
// Captures the emitted events into `sink` and forwards them to the underlying output
|
|
12
|
+
// function (so console behaviour is unchanged). Maps the engine's `type` to the canonical
|
|
13
|
+
// `kind`; `stepId` is intentionally not collected (the shared normalizer drops it — see R2).
|
|
14
|
+
export function createCapturingAddOutput(underlying, sink) {
|
|
15
|
+
return (item) => {
|
|
16
|
+
if (item && typeof item === "object") {
|
|
17
|
+
sink.push({
|
|
18
|
+
ts: typeof item.ts === "string" ? item.ts : null,
|
|
19
|
+
kind: typeof item.type === "string" ? item.type : "info",
|
|
20
|
+
text: typeof item.text === "string" ? item.text : "",
|
|
21
|
+
eventType: item.eventType,
|
|
22
|
+
actionType: item.actionType,
|
|
23
|
+
payload: item.payload,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return underlying(item);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// Derives the JUnit-style counts the shared status ladder expects from the CLI's
|
|
30
|
+
// boolean/error/event signals. Exactly one of {stopped, failures, errors} is ever set, so
|
|
31
|
+
// the shared precedence (error > fail > stopped > skipped > pass) yields the same verdict
|
|
32
|
+
// the CLI produced before — see R1.
|
|
33
|
+
function deriveCounts(success, error, events) {
|
|
34
|
+
if (success)
|
|
35
|
+
return { failures: 0, errors: 0, stopped: false };
|
|
36
|
+
if (String(error || "").toLowerCase().includes("stopped by user")) {
|
|
37
|
+
return { failures: 0, errors: 0, stopped: true };
|
|
38
|
+
}
|
|
39
|
+
const failedAssertion = events.some((event) => event.eventType === "assertion_result" && event.payload?.passed === false) ||
|
|
40
|
+
String(error || "").startsWith("Assertion failed:");
|
|
41
|
+
return failedAssertion ? { failures: 1, errors: 0, stopped: false } : { failures: 0, errors: 1, stopped: false };
|
|
42
|
+
}
|
|
43
|
+
// Builds a RunReportModel from captured CLI events plus the run's final stats. This is the
|
|
44
|
+
// CLI-acquisition adapter: it knows how to capture live events + run stats, then delegates
|
|
45
|
+
// 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.
|
|
47
|
+
export function buildCliRunReportModel({ suiteName, testName, startedAtIso, durationMs, success, error, instructionsTotal, stats = {}, events, }) {
|
|
48
|
+
const warnings = [];
|
|
49
|
+
const normalizedEvents = normalizeEvents(events, { fallbackTs: startedAtIso ?? null, warnings });
|
|
50
|
+
const summaryPayload = buildRunFinishedPayload({
|
|
51
|
+
success,
|
|
52
|
+
error,
|
|
53
|
+
durationMs,
|
|
54
|
+
instructionsTotal,
|
|
55
|
+
instructionsCompleted: stats.instructionsCompleted,
|
|
56
|
+
actionsTotal: stats.actionCount,
|
|
57
|
+
assertionsPassed: stats.assertionsPassed,
|
|
58
|
+
assertionsFailed: stats.assertionsFailed,
|
|
59
|
+
retries: stats.retryCount,
|
|
60
|
+
});
|
|
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
|
+
];
|
|
67
|
+
const { failures, errors, stopped } = deriveCounts(success, error, allEvents);
|
|
68
|
+
const status = deriveRunStatus({ tests: 1, failures, errors, stopped });
|
|
69
|
+
const successRate = computeSuccessRate({ tests: 1, failures, errors, status });
|
|
70
|
+
return assembleRunReportModel({
|
|
71
|
+
suiteName: suiteName ?? null,
|
|
72
|
+
testName: testName ?? null,
|
|
73
|
+
timestamp: startedAtIso ?? null,
|
|
74
|
+
durationSeconds: Number.isFinite(durationMs) ? durationMs / 1000 : null,
|
|
75
|
+
status,
|
|
76
|
+
testCases: 1,
|
|
77
|
+
successRate,
|
|
78
|
+
failureOrError: success
|
|
79
|
+
? undefined
|
|
80
|
+
: makeFailureOrError({ kind: status === "error" ? "error" : "failure", message: error ?? undefined }),
|
|
81
|
+
events: allEvents,
|
|
82
|
+
warnings,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
// Resolves where to write the report. A `--report <path>`:
|
|
86
|
+
// - ending in .html for a single run → that exact file
|
|
87
|
+
// - otherwise → treated as a directory; the file is named <test>--<date>--<time>.html
|
|
88
|
+
// With no path, writes <test>--<date>--<time>.html in the current working directory.
|
|
89
|
+
export function resolveReportOutputPath({ reportPath, testName, startedAtIso, cwd = process.cwd(), isBatch = false }) {
|
|
90
|
+
const fileName = buildReportFileName({ testName, timestamp: startedAtIso });
|
|
91
|
+
if (!reportPath) {
|
|
92
|
+
return path.join(cwd, fileName);
|
|
93
|
+
}
|
|
94
|
+
const resolved = path.resolve(cwd, reportPath);
|
|
95
|
+
if (/\.html?$/i.test(reportPath)) {
|
|
96
|
+
return isBatch ? path.join(path.dirname(resolved), fileName) : resolved;
|
|
97
|
+
}
|
|
98
|
+
return path.join(resolved, fileName);
|
|
99
|
+
}
|
|
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 } = {}) {
|
|
102
|
+
const model = buildCliRunReportModel(modelInput);
|
|
103
|
+
const outputPath = resolveReportOutputPath({
|
|
104
|
+
reportPath,
|
|
105
|
+
testName: modelInput.testName,
|
|
106
|
+
startedAtIso: modelInput.startedAtIso,
|
|
107
|
+
cwd,
|
|
108
|
+
isBatch,
|
|
109
|
+
});
|
|
110
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
111
|
+
await writeFile(outputPath, renderHtmlReport(model), "utf-8");
|
|
112
|
+
return outputPath;
|
|
113
|
+
}
|
|
@@ -8,6 +8,7 @@ import { ExecutionMode } from "../modes/execution-mode.js";
|
|
|
8
8
|
import { printCliOutput } from "../utils/console-output.js";
|
|
9
9
|
import { emitDesktopDebug } from "../utils/desktop-debug.js";
|
|
10
10
|
import { loadHeadlessInstructions } from "./headless-instructions.js";
|
|
11
|
+
import { createCapturingAddOutput, writeCliRunReport } from "./headless-report.js";
|
|
11
12
|
function sanitizePathSegment(value) {
|
|
12
13
|
return String(value || "test")
|
|
13
14
|
.replace(/\.dcua$/i, "")
|
|
@@ -55,13 +56,14 @@ function emitCliAppContextDebug(runId, appContext, appContextText) {
|
|
|
55
56
|
});
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
|
-
function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget }) {
|
|
59
|
+
function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget, secrets = null }) {
|
|
59
60
|
const executionEngineOptions = {
|
|
60
61
|
toolCapabilities: {
|
|
61
62
|
assert: true,
|
|
62
63
|
navigate: isBrowserTarget,
|
|
63
64
|
loadmill: true,
|
|
64
65
|
},
|
|
66
|
+
secretsByKey: secrets?.secretsByKey || {},
|
|
65
67
|
baseSystemPrompt: executionPrompt,
|
|
66
68
|
isHeadlessMode: true,
|
|
67
69
|
};
|
|
@@ -100,11 +102,17 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
|
|
|
100
102
|
}
|
|
101
103
|
return engineOptions;
|
|
102
104
|
}
|
|
103
|
-
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget }) {
|
|
105
|
+
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget, secrets = null, report = null, isBatchRun = false }) {
|
|
104
106
|
let executionMode = null;
|
|
107
|
+
const reportEnabled = Boolean(report?.enabled);
|
|
108
|
+
const collectedEvents = [];
|
|
109
|
+
const addOutput = reportEnabled
|
|
110
|
+
? createCapturingAddOutput(printCliOutput, collectedEvents)
|
|
111
|
+
: printCliOutput;
|
|
105
112
|
try {
|
|
106
113
|
await headlessDebug.startExecutionSession(runId, {
|
|
107
114
|
testName,
|
|
115
|
+
...(testDataContext ? { testDataUsed: true } : {}),
|
|
108
116
|
platform: deviceInfo.platform,
|
|
109
117
|
deviceName: deviceInfo.device_name,
|
|
110
118
|
aimAssistMode: headlessConfig.aimAssistMode
|
|
@@ -119,7 +127,11 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
119
127
|
emitCliAppContextDebug(runId, appContext, appContextText);
|
|
120
128
|
session.updateResponseId(undefined);
|
|
121
129
|
session.clearMessages();
|
|
122
|
-
const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, {
|
|
130
|
+
const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, {
|
|
131
|
+
aimAssistMode: headlessConfig.aimAssistMode,
|
|
132
|
+
testDataContext,
|
|
133
|
+
availableSecretKeys: secrets?.availableSecretKeys || [],
|
|
134
|
+
});
|
|
123
135
|
session.setSystemPrompt(executionPrompt);
|
|
124
136
|
const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
|
|
125
137
|
runId,
|
|
@@ -134,16 +146,19 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
134
146
|
recordScreenshots,
|
|
135
147
|
screenshotDir,
|
|
136
148
|
isBrowserTarget,
|
|
137
|
-
isLambdaTestTarget
|
|
149
|
+
isLambdaTestTarget,
|
|
150
|
+
secrets
|
|
138
151
|
});
|
|
139
152
|
const engine = new ExecutionEngine(session, engineOptions);
|
|
140
153
|
executionMode = new ExecutionMode(session, engine, instructions, true);
|
|
141
154
|
const result = await executionMode.execute({
|
|
142
155
|
runId,
|
|
143
|
-
addOutput
|
|
156
|
+
addOutput,
|
|
144
157
|
openAiClient: runtimeContext.openAiClient,
|
|
145
158
|
loadmillClient: runtimeContext.loadmillClient,
|
|
146
159
|
runtimeCredentials: runtimeContext.credentials,
|
|
160
|
+
// Inline screenshots as data URLs so the HTML report is self-contained.
|
|
161
|
+
...(reportEnabled ? { includeScreenshotDataUrl: true } : {}),
|
|
147
162
|
});
|
|
148
163
|
const stats = executionMode.stats || {};
|
|
149
164
|
const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
|
|
@@ -161,6 +176,26 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
161
176
|
if (!result.success) {
|
|
162
177
|
console.error(`\nTest failed: ${result.error}`);
|
|
163
178
|
}
|
|
179
|
+
if (reportEnabled) {
|
|
180
|
+
try {
|
|
181
|
+
const outputPath = await writeCliRunReport({
|
|
182
|
+
suiteName: path.basename(path.dirname(testPath)) || null,
|
|
183
|
+
testName,
|
|
184
|
+
startedAtIso: stats.startTime ? new Date(stats.startTime).toISOString() : null,
|
|
185
|
+
durationMs,
|
|
186
|
+
success: Boolean(result.success),
|
|
187
|
+
error: result.error ?? null,
|
|
188
|
+
instructionsTotal: instructions.length,
|
|
189
|
+
stats,
|
|
190
|
+
events: collectedEvents,
|
|
191
|
+
}, { reportPath: report?.path ?? null, isBatch: isBatchRun });
|
|
192
|
+
console.log(`HTML report saved: ${outputPath}`);
|
|
193
|
+
}
|
|
194
|
+
catch (reportError) {
|
|
195
|
+
const message = reportError instanceof Error ? reportError.message : String(reportError);
|
|
196
|
+
console.warn(`Warning: failed to write HTML report: ${message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
164
199
|
return {
|
|
165
200
|
success: Boolean(result.success),
|
|
166
201
|
error: result.error ?? null
|
|
@@ -188,9 +223,12 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
188
223
|
throw error;
|
|
189
224
|
}
|
|
190
225
|
}
|
|
191
|
-
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget }) {
|
|
192
|
-
const isBatchRun = instructionsTarget?.type === "directory";
|
|
193
|
-
if (
|
|
226
|
+
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget, report = null, runHeadlessTestImpl = runHeadlessTest }) {
|
|
227
|
+
const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
|
|
228
|
+
if (instructionsTarget?.type === "scenario") {
|
|
229
|
+
console.log(`\nRunning ${runs.length} scenario case${runs.length === 1 ? "" : "s"}\n`);
|
|
230
|
+
}
|
|
231
|
+
else if (isBatchRun) {
|
|
194
232
|
console.log(`\nRunning ${runs.length} tests from: ${instructionsTarget.path}\n`);
|
|
195
233
|
}
|
|
196
234
|
let failures = 0;
|
|
@@ -205,7 +243,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
205
243
|
if (recordScreenshots && testScreenshotDir) {
|
|
206
244
|
await mkdir(testScreenshotDir, { recursive: true });
|
|
207
245
|
}
|
|
208
|
-
const result = await
|
|
246
|
+
const result = await runHeadlessTestImpl({
|
|
209
247
|
runId,
|
|
210
248
|
testPath: run.testPath,
|
|
211
249
|
testName: run.testName,
|
|
@@ -214,12 +252,16 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
214
252
|
deviceInfo,
|
|
215
253
|
headlessConfig,
|
|
216
254
|
appContext,
|
|
255
|
+
testDataContext: run.testDataContext || "",
|
|
217
256
|
runtimeContext,
|
|
218
257
|
headlessDebug,
|
|
258
|
+
secrets,
|
|
219
259
|
recordScreenshots,
|
|
220
260
|
screenshotDir: testScreenshotDir,
|
|
221
261
|
isBrowserTarget,
|
|
222
|
-
isLambdaTestTarget
|
|
262
|
+
isLambdaTestTarget,
|
|
263
|
+
report,
|
|
264
|
+
isBatchRun
|
|
223
265
|
});
|
|
224
266
|
if (!result.success) {
|
|
225
267
|
failures += 1;
|
|
@@ -234,7 +276,11 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
234
276
|
console.error(`\nTest failed: ${run.testName}: ${message}`);
|
|
235
277
|
}
|
|
236
278
|
}
|
|
237
|
-
if (
|
|
279
|
+
if (instructionsTarget?.type === "scenario") {
|
|
280
|
+
const passed = runs.length - failures;
|
|
281
|
+
console.log(`\nScenario run complete: ${passed}/${runs.length} passed`);
|
|
282
|
+
}
|
|
283
|
+
else if (isBatchRun) {
|
|
238
284
|
const passed = runs.length - failures;
|
|
239
285
|
console.log(`\nFolder run complete: ${passed}/${runs.length} passed`);
|
|
240
286
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { planScenarioRuns, validateScenarioCases, } from "../core/scenario-planner.js";
|
|
@@ -26,6 +26,9 @@ export async function handleHelp(args, session, context) {
|
|
|
26
26
|
addOutput({ type: 'info', text: ' --os-version <version> LambdaTest OS version' });
|
|
27
27
|
addOutput({ type: 'info', text: ' --app <path> LambdaTest app package path (.apk or .ipa)' });
|
|
28
28
|
addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
|
|
29
|
+
addOutput({ type: 'info', text: ' --scenario <prompt> Generate and run scenario cases for headless instructions' });
|
|
30
|
+
addOutput({ type: 'info', text: ' --test-data <file> Optional test-data.md file for --scenario' });
|
|
31
|
+
addOutput({ type: 'info', text: ' --secrets <pairs> Runtime typing secrets as KEY=value,OTHER=value' });
|
|
29
32
|
addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
|
|
30
33
|
addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
|
|
31
34
|
addOutput({ type: 'info', text: ' --llm-provider <mode> Headless AI provider: loadmill or openai' });
|
|
@@ -80,6 +83,7 @@ export async function handleHelp(args, session, context) {
|
|
|
80
83
|
addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
|
|
81
84
|
addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
|
|
82
85
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
|
|
86
|
+
addOutput({ type: 'info', text: ' droid-cua --instructions tests --test-data tests/test-data.md --scenario "Run every checkout test with each buyer account"' });
|
|
83
87
|
addOutput({ type: 'info', text: ' droid-cua --target web --instructions tests/search.dcua' });
|
|
84
88
|
addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --platform android --device-name "Galaxy S24" --os-version 14 --app app.apk --instructions tests/login.dcua' });
|
|
85
89
|
addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
|
|
@@ -88,7 +88,8 @@ export async function handleRun(args, session, context) {
|
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
90
|
// Set execution mode system prompt (replaces any design mode prompt)
|
|
91
|
-
const
|
|
91
|
+
const availableSecretKeys = context.engine.getAvailableSecretKeys();
|
|
92
|
+
const executionPrompt = buildExecutionModePrompt(session.deviceInfo, {}, appContextText, { availableSecretKeys });
|
|
92
93
|
session.setSystemPrompt(executionPrompt);
|
|
93
94
|
// Create execution mode
|
|
94
95
|
const executionMode = new ExecutionMode(session, context.engine, instructions);
|