@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.
Files changed (39) hide show
  1. package/README.md +18 -0
  2. package/build/index.js +88 -1
  3. package/build/src/browser/playwright/actions.js +17 -8
  4. package/build/src/cli/headless-execution-config.js +21 -0
  5. package/build/src/cli/headless-report.js +113 -0
  6. package/build/src/cli/headless-runner.js +57 -11
  7. package/build/src/cli/scenario-planner.js +1 -0
  8. package/build/src/commands/help.js +4 -0
  9. package/build/src/commands/run.js +2 -1
  10. package/build/src/core/execution-engine.js +57 -2
  11. package/build/src/core/prompts.js +1 -1
  12. package/build/src/core/report/generate-html-report.js +88 -0
  13. package/build/src/core/report/parse-run-report.js +222 -0
  14. package/build/src/core/report/render-html-report.js +317 -0
  15. package/build/src/core/report/report-file-name.js +31 -0
  16. package/build/src/core/report/run-log-model.js +144 -0
  17. package/build/src/core/report/run-report-model.js +143 -0
  18. package/build/src/core/scenario-planner.js +85 -0
  19. package/build/src/core/secrets.js +116 -0
  20. package/build/src/core/test-data-import.js +62 -0
  21. package/build/src/core/test-data.js +103 -0
  22. package/build/src/core/tool-handlers/close-current-tab.js +3 -3
  23. package/build/src/core/tool-handlers/navigate.js +3 -3
  24. package/build/src/core/tool-handlers/preview-click.js +6 -6
  25. package/build/src/core/tool-handlers/type-sensitive-text.js +62 -0
  26. package/build/src/core/tool-handlers/utils.js +3 -3
  27. package/build/src/device/android/actions.js +13 -8
  28. package/build/src/device/cloud/actions.js +17 -8
  29. package/build/src/device/ios/actions.js +17 -8
  30. package/build/src/device/openai.js +17 -0
  31. package/build/src/device/sensitive-action.js +61 -0
  32. package/build/src/modes/design-mode-ink.js +3 -1
  33. package/build/src/modes/design-mode.js +3 -1
  34. package/build/src/modes/execution-mode.js +1 -0
  35. package/build/src/prompts/design.js +4 -3
  36. package/build/src/prompts/execution.js +27 -1
  37. package/build/src/prompts/scenario-planner.js +48 -0
  38. package/build/src/prompts/test-data-import.js +49 -0
  39. 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: typeof action?.text === "string" ? truncateForDebug(action.text, 300) : undefined,
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({ type: "action", text: `Typing text: ${text}`, ...buildMeta(context, action, { text }) });
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: error.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: error.message,
190
+ message: outputError.message,
183
191
  });
184
192
  addOutput({
185
193
  type: "error",
186
- text: `Error executing action: ${error.message}`,
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: error.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, { aimAssistMode: headlessConfig.aimAssistMode });
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: printCliOutput,
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 (isBatchRun) {
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 runHeadlessTest({
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 (isBatchRun) {
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 executionPrompt = buildExecutionModePrompt(session.deviceInfo, {}, appContextText);
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);