@loadmill/droid-cua 2.17.0 → 2.19.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 +16 -1
- package/build/src/browser/playwright/actions.js +17 -8
- package/build/src/cli/headless-execution-config.js +6 -0
- package/build/src/cli/headless-report.js +113 -0
- package/build/src/cli/headless-runner.js +40 -6
- package/build/src/commands/help.js +1 -0
- package/build/src/commands/run.js +2 -1
- package/build/src/core/execution-engine.js +125 -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 +146 -0
- package/build/src/core/report/run-report-model.js +143 -0
- package/build/src/core/secrets.js +116 -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 +17 -0
- package/build/src/prompts/design.js +4 -3
- package/build/src/prompts/execution.js +15 -1
- 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:
|
|
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: "",
|
|
@@ -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
|
|
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
|
|
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);
|