@loadmill/droid-cua 2.17.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 (33) hide show
  1. package/README.md +18 -0
  2. package/build/index.js +16 -1
  3. package/build/src/browser/playwright/actions.js +17 -8
  4. package/build/src/cli/headless-execution-config.js +6 -0
  5. package/build/src/cli/headless-report.js +113 -0
  6. package/build/src/cli/headless-runner.js +40 -6
  7. package/build/src/commands/help.js +1 -0
  8. package/build/src/commands/run.js +2 -1
  9. package/build/src/core/execution-engine.js +57 -2
  10. package/build/src/core/prompts.js +1 -1
  11. package/build/src/core/report/generate-html-report.js +88 -0
  12. package/build/src/core/report/parse-run-report.js +222 -0
  13. package/build/src/core/report/render-html-report.js +317 -0
  14. package/build/src/core/report/report-file-name.js +31 -0
  15. package/build/src/core/report/run-log-model.js +144 -0
  16. package/build/src/core/report/run-report-model.js +143 -0
  17. package/build/src/core/secrets.js +116 -0
  18. package/build/src/core/tool-handlers/close-current-tab.js +3 -3
  19. package/build/src/core/tool-handlers/navigate.js +3 -3
  20. package/build/src/core/tool-handlers/preview-click.js +6 -6
  21. package/build/src/core/tool-handlers/type-sensitive-text.js +62 -0
  22. package/build/src/core/tool-handlers/utils.js +3 -3
  23. package/build/src/device/android/actions.js +13 -8
  24. package/build/src/device/cloud/actions.js +17 -8
  25. package/build/src/device/ios/actions.js +17 -8
  26. package/build/src/device/openai.js +17 -0
  27. package/build/src/device/sensitive-action.js +61 -0
  28. package/build/src/modes/design-mode-ink.js +3 -1
  29. package/build/src/modes/design-mode.js +3 -1
  30. package/build/src/modes/execution-mode.js +1 -0
  31. package/build/src/prompts/design.js +4 -3
  32. package/build/src/prompts/execution.js +15 -1
  33. 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
@@ -14,6 +14,7 @@ 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
16
  import { resolveExecutionTestData } from "./src/core/test-data.js";
17
+ import { getDefaultSecretsPath, resolveExecutionSecrets } from "./src/core/secrets.js";
17
18
  import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
18
19
  import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
19
20
  import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
@@ -35,6 +36,13 @@ const startupUrl = typeof args.url === "string" && args.url.trim().length > 0 ?
35
36
  const instructionsFile = args.instructions || args.i || null;
36
37
  const appContextPath = typeof args.context === "string" ? args.context : null;
37
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
+ };
38
46
  const screenshotDir = path.join("droid-cua-recording-" + Date.now());
39
47
  if (recordScreenshots)
40
48
  await mkdir(screenshotDir, { recursive: true });
@@ -155,9 +163,14 @@ async function main() {
155
163
  let headlessInstructionRuns = [];
156
164
  let preloadedAppContext = null;
157
165
  let preloadedTestData = null;
166
+ let preloadedSecrets = null;
158
167
  if (isHeadlessInstructionsRun) {
159
168
  preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
160
169
  preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
170
+ preloadedSecrets = await resolveExecutionSecrets({
171
+ secretsPath: getDefaultSecretsPath(process.cwd()),
172
+ cliSecrets: preloadedHeadlessConfig.secretsOverride,
173
+ });
161
174
  if (preloadedHeadlessTarget.type === "directory") {
162
175
  const files = await listHeadlessInstructionFiles(preloadedHeadlessTarget.path);
163
176
  if (files.length === 0) {
@@ -315,12 +328,14 @@ async function main() {
315
328
  deviceInfo,
316
329
  headlessConfig,
317
330
  appContext,
331
+ secrets: preloadedSecrets,
318
332
  runtimeContext,
319
333
  headlessDebug,
320
334
  recordScreenshots,
321
335
  screenshotDir,
322
336
  isBrowserTarget,
323
- isLambdaTestTarget
337
+ isLambdaTestTarget,
338
+ report: reportConfig
324
339
  });
325
340
  exitCode = result.exitCode;
326
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: "",
@@ -184,6 +185,7 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
184
185
  const executionPromptText = typeof args["execution-prompt"] === "string" ? args["execution-prompt"] : null;
185
186
  const basePromptFilePath = typeof args["base-prompt-file"] === "string" ? path.resolve(cwd, args["base-prompt-file"]) : null;
186
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;
187
189
  if (explicitContextPath && noContext) {
188
190
  throw new Error("--context and --no-context cannot be used together.");
189
191
  }
@@ -199,6 +201,9 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
199
201
  if (contextOptimizationEnabledByFlag) {
200
202
  throw new Error("--context-optimization is not supported; Context Optimization is enabled by default.");
201
203
  }
204
+ if (secretsOverride != null) {
205
+ parseSecretsOverrideString(secretsOverride);
206
+ }
202
207
  if (noContextOptimization && hasContextOptimizationThresholdFlag) {
203
208
  throw new Error("--context-optimization-threshold and --no-context-optimization cannot be used together.");
204
209
  }
@@ -217,6 +222,7 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
217
222
  appContextPath: fileConfig?.appContextPath || null,
218
223
  scenarioPrompt,
219
224
  testDataPath,
225
+ secretsOverride,
220
226
  contextOptimizationEnabled: fileConfig?.contextOptimizationEnabled ?? true,
221
227
  contextOptimizationThreshold: fileConfig?.contextOptimizationThreshold ?? DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD,
222
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,8 +102,13 @@ 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, testDataContext = "", 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,
@@ -123,6 +130,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
123
130
  const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, {
124
131
  aimAssistMode: headlessConfig.aimAssistMode,
125
132
  testDataContext,
133
+ availableSecretKeys: secrets?.availableSecretKeys || [],
126
134
  });
127
135
  session.setSystemPrompt(executionPrompt);
128
136
  const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
@@ -138,16 +146,19 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
138
146
  recordScreenshots,
139
147
  screenshotDir,
140
148
  isBrowserTarget,
141
- isLambdaTestTarget
149
+ isLambdaTestTarget,
150
+ secrets
142
151
  });
143
152
  const engine = new ExecutionEngine(session, engineOptions);
144
153
  executionMode = new ExecutionMode(session, engine, instructions, true);
145
154
  const result = await executionMode.execute({
146
155
  runId,
147
- addOutput: printCliOutput,
156
+ addOutput,
148
157
  openAiClient: runtimeContext.openAiClient,
149
158
  loadmillClient: runtimeContext.loadmillClient,
150
159
  runtimeCredentials: runtimeContext.credentials,
160
+ // Inline screenshots as data URLs so the HTML report is self-contained.
161
+ ...(reportEnabled ? { includeScreenshotDataUrl: true } : {}),
151
162
  });
152
163
  const stats = executionMode.stats || {};
153
164
  const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
@@ -165,6 +176,26 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
165
176
  if (!result.success) {
166
177
  console.error(`\nTest failed: ${result.error}`);
167
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
+ }
168
199
  return {
169
200
  success: Boolean(result.success),
170
201
  error: result.error ?? null
@@ -192,7 +223,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
192
223
  throw error;
193
224
  }
194
225
  }
195
- export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget, runHeadlessTestImpl = runHeadlessTest }) {
226
+ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget, report = null, runHeadlessTestImpl = runHeadlessTest }) {
196
227
  const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
197
228
  if (instructionsTarget?.type === "scenario") {
198
229
  console.log(`\nRunning ${runs.length} scenario case${runs.length === 1 ? "" : "s"}\n`);
@@ -224,10 +255,13 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
224
255
  testDataContext: run.testDataContext || "",
225
256
  runtimeContext,
226
257
  headlessDebug,
258
+ secrets,
227
259
  recordScreenshots,
228
260
  screenshotDir: testScreenshotDir,
229
261
  isBrowserTarget,
230
- isLambdaTestTarget
262
+ isLambdaTestTarget,
263
+ report,
264
+ isBatchRun
231
265
  });
232
266
  if (!result.success) {
233
267
  failures += 1;
@@ -28,6 +28,7 @@ export async function handleHelp(args, session, context) {
28
28
  addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
29
29
  addOutput({ type: 'info', text: ' --scenario <prompt> Generate and run scenario cases for headless instructions' });
30
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' });
31
32
  addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
32
33
  addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
33
34
  addOutput({ type: 'info', text: ' --llm-provider <mode> Headless AI provider: loadmill or openai' });
@@ -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);
@@ -5,9 +5,11 @@ import { handleModelAction } from "../device/actions.js";
5
5
  import { sendCUARequest } from "../device/openai.js";
6
6
  import { executeLoadmillInstruction } from "../device/loadmill.js";
7
7
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
8
+ import { createAnnotatedScreenshotFromBase64 } from "../utils/run-screenshot-recorder.js";
8
9
  import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
9
10
  import { printCliOutput } from "../utils/console-output.js";
10
11
  import { CAPTURE_SOURCES } from "./capture-sources.js";
12
+ import { normalizeSecretKeys } from "./secrets.js";
11
13
  import { ScreenshotStore } from "./screenshot-store.js";
12
14
  import { handlePreviewClick } from "./tool-handlers/preview-click.js";
13
15
  import { handleNavigateToolCall } from "./tool-handlers/navigate.js";
@@ -15,6 +17,7 @@ import { handleCloseCurrentTabToolCall } from "./tool-handlers/close-current-tab
15
17
  import { handleAssertionToolCall } from "./tool-handlers/assert.js";
16
18
  import { handleLoadmillToolCall } from "./tool-handlers/loadmill.js";
17
19
  import { handleTestDesignCompleteToolCall } from "./tool-handlers/test-design-complete.js";
20
+ import { handleTypeSensitiveTextToolCall } from "./tool-handlers/type-sensitive-text.js";
18
21
  import { buildDebugContext, buildTextToolOutputItem, } from "./tool-handlers/utils.js";
19
22
  function extractActionableOutputItems(items) {
20
23
  const entries = [];
@@ -77,11 +80,33 @@ export class ExecutionEngine {
77
80
  this.closeCurrentTab = options.closeCurrentTab || closeCurrentBrowserTab;
78
81
  this.executeLoadmillInstruction = options.executeLoadmillInstruction || executeLoadmillInstruction;
79
82
  this.toolCapabilities = options.toolCapabilities || {};
83
+ this.secretsByKey = options.secretsByKey && typeof options.secretsByKey === "object" ? options.secretsByKey : {};
80
84
  this.baseSystemPrompt = options.baseSystemPrompt || session?.systemPrompt || null;
81
85
  this.isHeadlessMode = options.isHeadlessMode === true;
82
86
  }
87
+ hasSecrets() {
88
+ return this.getAvailableSecretKeys().length > 0;
89
+ }
90
+ getAvailableSecretKeys() {
91
+ return normalizeSecretKeys(Object.keys(this.secretsByKey));
92
+ }
83
93
  async recordScreenshot(screenshotBase64, metadata = {}) {
84
- return await this.screenshotStore.saveScreenshot(screenshotBase64, metadata);
94
+ const saved = await this.screenshotStore.saveScreenshot(screenshotBase64, metadata);
95
+ if (saved) {
96
+ return saved;
97
+ }
98
+ // No recorder/persistence (e.g. a plain CLI `--report` run): still hold onto the raw
99
+ // base64 so downstream click-annotation can render the marker in-memory for the report.
100
+ if (typeof screenshotBase64 !== "string" || !screenshotBase64) {
101
+ return null;
102
+ }
103
+ return {
104
+ base64: screenshotBase64,
105
+ artifactPaths: [],
106
+ annotationSourcePaths: [],
107
+ screenshotPath: null,
108
+ screenshotDataUrl: null,
109
+ };
85
110
  }
86
111
  async annotateScreenshot(currentScreenshot, action, metadata = {}) {
87
112
  if (!(action?.type === "click" || action?.type === "double_click")) {
@@ -89,6 +114,30 @@ export class ExecutionEngine {
89
114
  }
90
115
  return await this.screenshotStore.annotateScreenshot(currentScreenshot, { x: action.x, y: action.y, style: "click" }, { ...metadata, actionType: action.type });
91
116
  }
117
+ // Resolves the click-annotation data URL for an action event when a self-contained
118
+ // report is being produced (`includeScreenshotDataUrl`). Prefers the recorder's own
119
+ // annotated data URL (Desktop); otherwise renders the green "click" marker in-memory
120
+ // from the raw screenshot so reports work even without a disk recorder.
121
+ async resolveAnnotatedDataUrl(annotatedScreenshot, currentScreenshot, action, context) {
122
+ if (!context?.includeScreenshotDataUrl) {
123
+ return null;
124
+ }
125
+ if (annotatedScreenshot?.screenshotDataUrl) {
126
+ return annotatedScreenshot.screenshotDataUrl;
127
+ }
128
+ if (!(action?.type === "click" || action?.type === "double_click")) {
129
+ return null;
130
+ }
131
+ if (typeof currentScreenshot?.base64 !== "string" || !currentScreenshot.base64) {
132
+ return null;
133
+ }
134
+ const annotated = await createAnnotatedScreenshotFromBase64(currentScreenshot.base64, {
135
+ x: action.x,
136
+ y: action.y,
137
+ style: "click",
138
+ });
139
+ return annotated?.screenshotBase64 ? `data:image/png;base64,${annotated.screenshotBase64}` : null;
140
+ }
92
141
  /**
93
142
  * Dispatches a function-call tool to its handler. Each handler returns a
94
143
  * discriminated outcome that runFullTurn interprets below. Continue and
@@ -108,6 +157,8 @@ export class ExecutionEngine {
108
157
  return await handleCloseCurrentTabToolCall(handlerArgs);
109
158
  case "loadmill":
110
159
  return await handleLoadmillToolCall(handlerArgs);
160
+ case "type_sensitive_text":
161
+ return await handleTypeSensitiveTextToolCall(handlerArgs);
111
162
  case "test_design_complete":
112
163
  return await handleTestDesignCompleteToolCall(handlerArgs);
113
164
  default:
@@ -146,6 +197,10 @@ export class ExecutionEngine {
146
197
  eventId: actionEventId,
147
198
  captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION
148
199
  });
200
+ // Inline the green click marker as a data URL so self-contained reports show it.
201
+ // The Desktop recorder returns one from disk; for runs without a recorder (e.g. a
202
+ // plain CLI `--report`) render the same marker in-memory from the raw screenshot.
203
+ const annotatedDataUrl = await this.resolveAnnotatedDataUrl(annotatedScreenshot, currentScreenshot, action, context);
149
204
  await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
150
205
  ...context,
151
206
  shouldStop,
@@ -154,7 +209,7 @@ export class ExecutionEngine {
154
209
  eventId: actionEventId,
155
210
  captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
156
211
  ...(annotatedScreenshot?.screenshotPath ? { screenshotPath: annotatedScreenshot.screenshotPath } : {}),
157
- ...(context?.includeScreenshotDataUrl && annotatedScreenshot?.screenshotDataUrl ? { screenshotDataUrl: annotatedScreenshot.screenshotDataUrl } : {})
212
+ ...(context?.includeScreenshotDataUrl && annotatedDataUrl ? { screenshotDataUrl: annotatedDataUrl } : {})
158
213
  });
159
214
  if (trackAction) {
160
215
  trackAction(action);
@@ -1,3 +1,3 @@
1
1
  export { buildBaseSystemPrompt } from "../prompts/base.js";
2
2
  export { buildDesignModePrompt, buildDesignRecoveryPrompt, buildDesignScriptGenerationPrompt, } from "../prompts/design.js";
3
- export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, buildTestDataContextSection, } from "../prompts/execution.js";
3
+ export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, buildSecretKeysContextSection, buildTestDataContextSection, } from "../prompts/execution.js";
@@ -0,0 +1,88 @@
1
+ // File/glue layer for the HTML report: reads a stored JUnit run-result XML, inlines
2
+ // its sibling screenshot PNGs as base64 data URLs, and renders a self-contained HTML
3
+ // document. Used by both the CLI (`--report`) and the Desktop App ("Share" button).
4
+ import { readFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { parseRunReport } from "./parse-run-report.js";
7
+ import { renderHtmlReport } from "./render-html-report.js";
8
+ const MIME_BY_EXT = {
9
+ ".png": "image/png",
10
+ ".jpg": "image/jpeg",
11
+ ".jpeg": "image/jpeg",
12
+ ".webp": "image/webp",
13
+ ".gif": "image/gif",
14
+ };
15
+ function mimeForPath(filePath) {
16
+ return MIME_BY_EXT[path.extname(filePath).toLowerCase()] ?? "image/png";
17
+ }
18
+ // Resolves a screenshotPath (relative to the results directory) to an absolute path,
19
+ // guarding against escaping the results directory via traversal. Returns null if the
20
+ // resolved path would fall outside `resultsDir`.
21
+ function resolveScreenshotPath(resultsDir, screenshotPath) {
22
+ if (typeof screenshotPath !== "string" || screenshotPath.length === 0)
23
+ return null;
24
+ const resolved = path.resolve(resultsDir, screenshotPath);
25
+ const base = path.resolve(resultsDir);
26
+ if (resolved !== base && !resolved.startsWith(base + path.sep))
27
+ return null;
28
+ return resolved;
29
+ }
30
+ // Reads an image file and returns a data URL, or null if it cannot be read.
31
+ async function toDataUrl(filePath) {
32
+ try {
33
+ const buffer = await readFile(filePath);
34
+ return `data:${mimeForPath(filePath)};base64,${buffer.toString("base64")}`;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ // Walks every event (top-level and child tests) and replaces payload.screenshotPath
41
+ // with an inlined payload.screenshotDataUrl read from disk. Missing files are skipped.
42
+ async function inlineScreenshots(model, resultsDir) {
43
+ const allEventLists = [model.events ?? []];
44
+ for (const child of model.childTests ?? []) {
45
+ allEventLists.push(child.events ?? []);
46
+ }
47
+ for (const events of allEventLists) {
48
+ for (const event of events) {
49
+ const screenshotPath = event.payload?.screenshotPath;
50
+ if (typeof screenshotPath !== "string" || screenshotPath.length === 0)
51
+ continue;
52
+ if (typeof event.payload?.screenshotDataUrl === "string")
53
+ continue; // already inlined
54
+ const absolute = resolveScreenshotPath(resultsDir, screenshotPath);
55
+ if (!absolute)
56
+ continue;
57
+ const dataUrl = await toDataUrl(absolute);
58
+ if (dataUrl) {
59
+ event.payload = { ...event.payload, screenshotDataUrl: dataUrl };
60
+ }
61
+ }
62
+ }
63
+ return model;
64
+ }
65
+ // Builds the HTML report from an in-memory model. Screenshots are inlined from
66
+ // `resultsDir` (the directory the relative screenshotPath values are based on).
67
+ export async function generateHtmlReportFromModel(model, { resultsDir, title } = {}) {
68
+ if (resultsDir) {
69
+ await inlineScreenshots(model, resultsDir);
70
+ }
71
+ return renderHtmlReport(model, { title });
72
+ }
73
+ // Reads a stored run-result XML file and produces both the self-contained HTML report
74
+ // and the parsed model (so callers can derive a filename from testName/timestamp without
75
+ // re-reading the file). Screenshots are read from the sibling directory the relative
76
+ // screenshotPath values reference (which is the XML file's own directory).
77
+ export async function generateReportFromXmlFile(xmlPath, { title } = {}) {
78
+ const xml = await readFile(xmlPath, "utf-8");
79
+ const model = parseRunReport(xml);
80
+ const resultsDir = path.dirname(path.resolve(xmlPath));
81
+ const html = await generateHtmlReportFromModel(model, { resultsDir, title });
82
+ return { html, model };
83
+ }
84
+ // Convenience wrapper returning just the HTML for callers that don't need the model.
85
+ export async function generateHtmlReportFromXmlFile(xmlPath, options = {}) {
86
+ const { html } = await generateReportFromXmlFile(xmlPath, options);
87
+ return html;
88
+ }