@loadmill/droid-cua 2.44.0 → 2.45.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 +11 -3
- package/build/index.js +7 -2
- package/build/src/cli/headless-execution-config.js +9 -0
- package/build/src/cli/headless-report.js +40 -11
- package/build/src/cli/headless-runner.js +28 -14
- package/build/src/commands/help.js +1 -0
- package/build/src/commands/stop.js +1 -0
- package/build/src/core/capture-sources.js +4 -0
- package/build/src/core/droid-cua-run-usage.js +9 -2
- package/build/src/core/execution-context.js +8 -0
- package/build/src/core/report/render-html-report.js +299 -94
- package/build/src/core/report/run-log-model.js +84 -1
- package/build/src/core/report/run-report-model.js +4 -1
- package/build/src/core/report/run-report-sections.js +10 -0
- package/build/src/core/runtime-config.js +11 -1
- package/build/src/core/step-watchdog.js +61 -0
- package/build/src/modes/execution-mode.js +157 -3
- package/build/src/prompts/step-watchdog.js +22 -0
- package/build/src/utils/desktop-debug.js +2 -2
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +1 -1
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +4 -4
- package/skill/droid-cua/references/running-and-healing.md +16 -0
package/README.md
CHANGED
|
@@ -58,10 +58,16 @@ models use your own OpenAI API key.
|
|
|
58
58
|
- Android: connect a device or select an emulator
|
|
59
59
|
- iOS: choose a simulator on macOS
|
|
60
60
|
|
|
61
|
+
No app of your own yet? The **Apps** page includes a built-in **Demo Shop**
|
|
62
|
+
Android app. Install it on a connected Android device or emulator, or pick it
|
|
63
|
+
for a cloud Android session, to try the agent right away.
|
|
64
|
+
|
|
61
65
|
**5. Create or run a test**
|
|
62
66
|
|
|
63
67
|
Use the desktop app to create a new test, edit an existing one, or run a saved script with live execution logs.
|
|
64
68
|
|
|
69
|
+
Use the Back and Forward buttons next to the sidebar toggle to revisit pages and selections. Pending editor changes are saved before Back or Forward leaves the page. Automatic batch-test transitions do not add history entries. Report summaries remain visible while switching between saved runs. Navigation history lasts for the current app session; opening a new destination after going back clears forward history.
|
|
70
|
+
|
|
65
71
|
You can also keep project run history in a results folder and review past runs from desktop app reports.
|
|
66
72
|
|
|
67
73
|
---
|
|
@@ -99,7 +105,7 @@ Desktop app support:
|
|
|
99
105
|
- **Desktop-first workflow** - Create, run, and manage tests from one app
|
|
100
106
|
- **Setup guidance** - Configure API access and platform prerequisites in the app
|
|
101
107
|
- **Device and simulator connection** - Connect Android targets and iOS simulators
|
|
102
|
-
- **Network certificate preparation** - Manage one active root CA and install it on a connected local simulator or supported emulator, or get manual setup steps for a physical iPhone or Android phone, from the desktop app's
|
|
108
|
+
- **Network certificate preparation** - Manage one active root CA and install it on a connected local simulator or supported emulator, or get manual setup steps for a physical iPhone or Android phone, from the desktop app's Settings → Network capture page
|
|
103
109
|
- **Natural-language test creation** - Describe flows in plain English
|
|
104
110
|
- **Test management** - Create, edit, save, and rerun reusable scripts, including tests organized in nested project folders
|
|
105
111
|
- **Live execution logs** - Watch actions and progress as tests run
|
|
@@ -108,7 +114,7 @@ Desktop app support:
|
|
|
108
114
|
- **Run video export** - Generate and download a polished MP4 replay from a desktop run's screenshots and instruction timeline
|
|
109
115
|
- **Headless support** - Reuse scripts in CLI and automation workflows
|
|
110
116
|
|
|
111
|
-
**Network certificate preparation:** Use **
|
|
117
|
+
**Network certificate preparation:** Use **Settings → Network capture → Manage certificates** to see the active CA, upload a single `.pem` containing a CA certificate and its matching unencrypted private key, generate a new Droid CA, or remove the active CA. Droid stores one active pair in `~/.droid-cua/network/`; upload copies the PEM material without changing the source file, and removal does not change device trust. Connect a local iOS Simulator or a rootable Android Emulator, then choose **Settings → Network capture → Install on connected device**. This installs the active CA, generating a Droid CA if none exists. Devices prepared with a previous CA may need installation again. Droid may reapply Android system trust when a prepared emulator reconnects after a cold boot. Restart an already running Android app after installation so it loads the updated trust store. With a local iPhone connected by USB, **Settings → Network capture → Install on connected device** does not change the phone; it shows the manual steps instead. **Send to iPhone** opens the macOS share menu with a DER `.cer` copy of the active CA named after its common name (for example `Droid CUA Root fbd877ab.cer`, written to `~/.droid-cua/network/iphone/`); choose AirDrop and your iPhone, install it from **Settings → Profile Downloaded**, then turn on full trust for that common name under **Settings → General → About → Certificate Trust Settings**. Android Emulator certificate setup supports capable rootable images through Android 16; Google Play images without root access and Android 17 are not supported. With a physical Android phone connected over ADB, **Settings → Network capture → Install on connected device** writes a PEM `.crt` copy of the active CA named after its common name (for example `Droid CUA Root fbd877ab.crt`, in `~/.droid-cua/network/android/`), copies it to the phone's `/sdcard/Download/` folder with `adb push`, and shows the manual steps; it changes no phone settings. Install it from **Settings → Security → Encryption & credentials → Install a certificate → CA certificate** (names vary by vendor), then restart the tested app. If the copy fails, Droid shows the local file path so you can copy it to the phone yourself.
|
|
112
118
|
|
|
113
119
|
**Network capture:** Enable **Network Capture** in desktop Settings or pass `--capture-network` for CLI tests. Local Chrome and Edge capture through Playwright with no certificate or proxy setup, using the same network queries and report cards as mobile. Capture covers the test browser context, including frames and popups, from run start. Previously loaded traffic is not recovered and existing pages are not automatically reloaded. CLI `--url` navigation happens after capture starts. Cloud browser capture, WebSocket messages and HAR export are not supported.
|
|
114
120
|
|
|
@@ -319,6 +325,7 @@ Supported CLI options include:
|
|
|
319
325
|
- `--context-optimization-threshold`
|
|
320
326
|
- `--no-context-optimization`
|
|
321
327
|
- `--explain`
|
|
328
|
+
- `--step-watchdog <off|on>`
|
|
322
329
|
- `--base-prompt`
|
|
323
330
|
- `--execution-prompt`
|
|
324
331
|
- `--base-prompt-file`
|
|
@@ -359,6 +366,7 @@ Config and precedence rules:
|
|
|
359
366
|
- `--no-context-optimization` disables Context Optimization.
|
|
360
367
|
- Explain Mode is enabled by default and asks the agent for concise explanations of non-obvious decisions, skipped actions, and completion evidence; explanations appear in terminal output and reports.
|
|
361
368
|
- Set `"explainMode": false` in the JSON config to disable it. The `--explain` flag remains available to explicitly enable it for a run.
|
|
369
|
+
- Step Watchdog is experimental and defaults to `off`. Use `--step-watchdog on` to check for failed operations or blocked execution, or `--step-watchdog off` to disable checks. The JSON field is `"stepWatchdogMode": "on"`; the CLI flag overrides it. Desktop users can toggle **Settings → Agent → Verify Step Results** on or off. Successful checks run silently. Failures use an assertion-style **Step Failed** card with the expected instruction, actor explanation, and screenshot. Interactive runs offer **Skip** to continue or **Stop** to fail; headless runs fail automatically. Evaluator errors end execution. Enable `--debug` or desktop **Debug Mode** to inspect `step_watchdog.result` entries; probabilities and evaluator diagnostics remain out of normal output and reports. See [Step Watchdog](docs/step-watchdog.md) for provider requirements, logging, and calibration.
|
|
362
370
|
- `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
|
|
363
371
|
- `--base-prompt-file` and `--execution-prompt-file` override the corresponding prompt customizations from config.
|
|
364
372
|
- If both inline and file-based prompt overrides are provided, the inline prompt flags win.
|
|
@@ -560,7 +568,7 @@ droid-cua \
|
|
|
560
568
|
|
|
561
569
|
Headless debug artifacts:
|
|
562
570
|
- `--debug` writes desktop-style structured JSONL artifacts under `logs/`.
|
|
563
|
-
- Each run creates `logs/execution-<runId>-<timestamp>.jsonl`.
|
|
571
|
+
- Each run creates `logs/execution-<runId>-<timestamp>.jsonl`. New run IDs are UUIDs; a batch shares one ID across its tests and aggregate report, and a rerun gets a new ID.
|
|
564
572
|
- Each run also creates a sibling screenshot folder next to that JSONL file.
|
|
565
573
|
- Shared device events are written to `logs/device-events.jsonl`.
|
|
566
574
|
- `--debug` no longer creates the legacy `logs/debug-*.log` file for headless runs.
|
package/build/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { withExecutionRunId } from "./src/core/execution-context.js";
|
|
1
3
|
const processStartTime = Date.now();
|
|
2
4
|
import minimist from "minimist";
|
|
3
5
|
import dotenv from "dotenv";
|
|
@@ -850,6 +852,7 @@ async function main() {
|
|
|
850
852
|
session.setSystemPrompt(initialSystemText);
|
|
851
853
|
// If tests were selected, run in headless mode.
|
|
852
854
|
if (isHeadlessInstructionsRun) {
|
|
855
|
+
const runId = randomUUID();
|
|
853
856
|
let exitCode = null;
|
|
854
857
|
let succeeded = false;
|
|
855
858
|
try {
|
|
@@ -860,6 +863,7 @@ async function main() {
|
|
|
860
863
|
llmProviderMode: headlessConfig.llmProviderMode,
|
|
861
864
|
cuaModel: headlessConfig.cuaModel,
|
|
862
865
|
explainMode: headlessConfig.explainMode,
|
|
866
|
+
stepWatchdogMode: headlessConfig.stepWatchdogMode,
|
|
863
867
|
promptCustomizations: headlessConfig.promptCustomizations,
|
|
864
868
|
contextOptimizationEnabled: headlessConfig.contextOptimizationEnabled,
|
|
865
869
|
contextOptimizationThreshold: headlessConfig.contextOptimizationThreshold,
|
|
@@ -875,7 +879,7 @@ async function main() {
|
|
|
875
879
|
console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
|
|
876
880
|
if (headlessConfig.scenarioPrompt) {
|
|
877
881
|
const candidateRuns = await loadScenarioCandidateRuns(headlessInstructionRuns);
|
|
878
|
-
const scenarioCases = await planScenarioRuns({
|
|
882
|
+
const scenarioCases = await withExecutionRunId(runId, () => planScenarioRuns({
|
|
879
883
|
client: runtimeContext.openAiClient,
|
|
880
884
|
runtimeConfig: runtimeContext.config,
|
|
881
885
|
scenarioPrompt: headlessConfig.scenarioPrompt,
|
|
@@ -885,7 +889,7 @@ async function main() {
|
|
|
885
889
|
})),
|
|
886
890
|
appContextText,
|
|
887
891
|
testDataText: preloadedTestData?.text || "",
|
|
888
|
-
});
|
|
892
|
+
}));
|
|
889
893
|
printScenarioCasePreview(scenarioCases);
|
|
890
894
|
headlessInstructionRuns = buildScenarioRuns({
|
|
891
895
|
candidateRuns,
|
|
@@ -894,6 +898,7 @@ async function main() {
|
|
|
894
898
|
preloadedHeadlessTarget = buildScenarioInstructionTarget(preloadedHeadlessTarget, headlessConfig.scenarioPrompt);
|
|
895
899
|
}
|
|
896
900
|
const result = await runHeadlessInstructionRuns({
|
|
901
|
+
runId,
|
|
897
902
|
runs: headlessInstructionRuns,
|
|
898
903
|
instructionsTarget: preloadedHeadlessTarget,
|
|
899
904
|
session,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
+
import { parseStepWatchdogMode } from "../core/step-watchdog.js";
|
|
2
3
|
import { readFile } from "fs/promises";
|
|
3
4
|
import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, MAX_CONTEXT_OPTIMIZATION_THRESHOLD, MIN_CONTEXT_OPTIMIZATION_THRESHOLD, } from "../core/context-optimization.js";
|
|
4
5
|
import { AIM_ASSIST_MODES, DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
|
|
@@ -131,6 +132,7 @@ async function loadConfigFromFile(configPath) {
|
|
|
131
132
|
cuaModel: undefined,
|
|
132
133
|
aimAssistMode: undefined,
|
|
133
134
|
explainMode: undefined,
|
|
135
|
+
stepWatchdogMode: undefined,
|
|
134
136
|
promptCustomizations: createEmptyPromptCustomizations(),
|
|
135
137
|
appContextEnabled: undefined,
|
|
136
138
|
appContextPath: undefined,
|
|
@@ -152,6 +154,9 @@ async function loadConfigFromFile(configPath) {
|
|
|
152
154
|
}
|
|
153
155
|
normalized.explainMode = rawConfig.explainMode;
|
|
154
156
|
}
|
|
157
|
+
if ("stepWatchdogMode" in rawConfig) {
|
|
158
|
+
normalized.stepWatchdogMode = parseStepWatchdogMode(rawConfig.stepWatchdogMode);
|
|
159
|
+
}
|
|
155
160
|
if ("promptCustomizations" in rawConfig) {
|
|
156
161
|
normalized.promptCustomizations = normalizePromptCustomizations(rawConfig.promptCustomizations, "config.promptCustomizations");
|
|
157
162
|
}
|
|
@@ -226,6 +231,7 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
226
231
|
cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
|
|
227
232
|
aimAssistMode: fileConfig?.aimAssistMode || DEFAULT_AIM_ASSIST_MODE,
|
|
228
233
|
explainMode: fileConfig?.explainMode ?? true,
|
|
234
|
+
stepWatchdogMode: fileConfig?.stepWatchdogMode ?? "off",
|
|
229
235
|
promptCustomizations,
|
|
230
236
|
appContextEnabled: fileConfig?.appContextEnabled ?? true,
|
|
231
237
|
appContextPath: fileConfig?.appContextPath || null,
|
|
@@ -247,6 +253,9 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
247
253
|
if (args.explain === true) {
|
|
248
254
|
resolved.explainMode = true;
|
|
249
255
|
}
|
|
256
|
+
if (args["step-watchdog"] !== undefined) {
|
|
257
|
+
resolved.stepWatchdogMode = parseStepWatchdogMode(args["step-watchdog"]);
|
|
258
|
+
}
|
|
250
259
|
if (hasContextOptimizationThresholdFlag) {
|
|
251
260
|
resolved.contextOptimizationEnabled = true;
|
|
252
261
|
resolved.contextOptimizationThreshold = parseContextOptimizationThreshold(args["context-optimization-threshold"], "--context-optimization-threshold");
|
|
@@ -16,7 +16,7 @@ export function createCapturingAddOutput(underlying, sink, { cloudSink = null, u
|
|
|
16
16
|
return (item) => {
|
|
17
17
|
if (item && typeof item === "object") {
|
|
18
18
|
const event = {
|
|
19
|
-
ts: typeof item.ts === "string" ? item.ts :
|
|
19
|
+
ts: typeof item.ts === "string" && item.ts.trim() ? item.ts : new Date().toISOString(),
|
|
20
20
|
kind: typeof item.type === "string" ? item.type : "info",
|
|
21
21
|
text: typeof item.text === "string" ? item.text : "",
|
|
22
22
|
eventType: item.eventType,
|
|
@@ -45,26 +45,52 @@ export function createCapturingAddOutput(underlying, sink, { cloudSink = null, u
|
|
|
45
45
|
// boolean/error/event signals. Exactly one of {stopped, failures, errors} is ever set, so
|
|
46
46
|
// the shared precedence (error > fail > stopped > skipped > pass) yields the same verdict
|
|
47
47
|
// the CLI produced before — see R1.
|
|
48
|
-
function deriveCounts(success, error, events) {
|
|
48
|
+
function deriveCounts(success, error, events, failureKind) {
|
|
49
49
|
if (success)
|
|
50
50
|
return { failures: 0, errors: 0, stopped: false };
|
|
51
51
|
if (String(error || "").toLowerCase().includes("stopped by user")) {
|
|
52
52
|
return { failures: 0, errors: 0, stopped: true };
|
|
53
53
|
}
|
|
54
|
+
if (failureKind === "step_watchdog_error")
|
|
55
|
+
return { failures: 0, errors: 1, stopped: false };
|
|
56
|
+
if (failureKind === "step_watchdog")
|
|
57
|
+
return { failures: 1, errors: 0, stopped: false };
|
|
54
58
|
const failedAssertion = events.some((event) => event.eventType === "assertion_result" && event.payload?.passed === false) ||
|
|
55
59
|
String(error || "").startsWith("Assertion failed:");
|
|
56
60
|
return failedAssertion ? { failures: 1, errors: 0, stopped: false } : { failures: 0, errors: 1, stopped: false };
|
|
57
61
|
}
|
|
62
|
+
function deviceReportEvents(deviceInfo, startedAtIso, testName) {
|
|
63
|
+
if (!deviceInfo)
|
|
64
|
+
return [];
|
|
65
|
+
return [{
|
|
66
|
+
ts: startedAtIso ?? null,
|
|
67
|
+
kind: "system",
|
|
68
|
+
text: `Loading test: ${testName ?? "Test run"}`,
|
|
69
|
+
eventType: "run_started",
|
|
70
|
+
payload: {
|
|
71
|
+
deviceName: deviceInfo.device_name,
|
|
72
|
+
platform: deviceInfo.platform,
|
|
73
|
+
deviceSource: deviceInfo.source,
|
|
74
|
+
osVersion: deviceInfo.os_version,
|
|
75
|
+
},
|
|
76
|
+
}];
|
|
77
|
+
}
|
|
58
78
|
// Builds a RunReportModel from captured CLI events plus the run's final stats. This is the
|
|
59
79
|
// CLI-acquisition adapter: it knows how to capture live events + run stats, then delegates
|
|
60
80
|
// ALL model assembly to the shared run-report-model.js (same code path as the XML/Desktop
|
|
61
81
|
// parser). A `run_finished` event is synthesized when the captured output stream omits one.
|
|
62
|
-
export function buildCliRunReportModel({ suiteName, testName, startedAtIso, durationMs, success, error, instructionsTotal, stats = {}, events, }) {
|
|
82
|
+
export function buildCliRunReportModel({ suiteName, testName, startedAtIso, durationMs, success, error, failureKind = undefined, stepWatchdogMode = undefined, stepWatchdogUsage = undefined, instructionsTotal, stats = {}, events, deviceInfo = undefined, }) {
|
|
63
83
|
const warnings = [];
|
|
64
84
|
const normalizedEvents = normalizeEvents(events, { fallbackTs: startedAtIso ?? null, warnings });
|
|
85
|
+
const terminalCheck = normalizedEvents.findLast((event) => event.eventType === "step_watchdog_result" && event.payload?.mode === "on");
|
|
86
|
+
failureKind ??= terminalCheck?.payload?.outcome === "failure" ? "step_watchdog"
|
|
87
|
+
: terminalCheck?.payload?.outcome === "error" ? "step_watchdog_error" : undefined;
|
|
65
88
|
const summaryPayload = buildRunFinishedPayload({
|
|
66
89
|
success,
|
|
67
90
|
error,
|
|
91
|
+
failureKind,
|
|
92
|
+
stepWatchdogMode,
|
|
93
|
+
stepWatchdogUsage,
|
|
68
94
|
durationMs,
|
|
69
95
|
instructionsTotal,
|
|
70
96
|
instructionsCompleted: stats.instructionsCompleted,
|
|
@@ -74,15 +100,18 @@ export function buildCliRunReportModel({ suiteName, testName, startedAtIso, dura
|
|
|
74
100
|
retries: stats.retryCount,
|
|
75
101
|
});
|
|
76
102
|
const hasRunFinished = normalizedEvents.some((event) => event.eventType === "run_finished");
|
|
77
|
-
//
|
|
78
|
-
|
|
103
|
+
// Use execution time, not report generation/upload time, for the final step boundary.
|
|
104
|
+
const startedAtMs = Date.parse(startedAtIso);
|
|
105
|
+
const finishedAt = Number.isFinite(startedAtMs) && Number.isFinite(durationMs) && durationMs >= 0
|
|
106
|
+
? new Date(startedAtMs + durationMs).toISOString()
|
|
107
|
+
: null;
|
|
79
108
|
const allEvents = hasRunFinished
|
|
80
109
|
? normalizedEvents
|
|
81
110
|
: [
|
|
82
111
|
...normalizedEvents,
|
|
83
|
-
{ ts:
|
|
112
|
+
{ ts: finishedAt, kind: "system", text: "Execution finished", eventType: "run_finished", payload: summaryPayload },
|
|
84
113
|
];
|
|
85
|
-
const { failures, errors, stopped } = deriveCounts(success, error, allEvents);
|
|
114
|
+
const { failures, errors, stopped } = deriveCounts(success, error, allEvents, failureKind);
|
|
86
115
|
const status = deriveRunStatus({ tests: 1, failures, errors, stopped });
|
|
87
116
|
const successRate = computeSuccessRate({ tests: 1, failures, errors, status });
|
|
88
117
|
return assembleRunReportModel({
|
|
@@ -96,7 +125,7 @@ export function buildCliRunReportModel({ suiteName, testName, startedAtIso, dura
|
|
|
96
125
|
failureOrError: success
|
|
97
126
|
? undefined
|
|
98
127
|
: makeFailureOrError({ kind: status === "error" ? "error" : "failure", message: error ?? undefined }),
|
|
99
|
-
events: allEvents,
|
|
128
|
+
events: [...deviceReportEvents(deviceInfo, startedAtIso, testName), ...allEvents],
|
|
100
129
|
warnings,
|
|
101
130
|
});
|
|
102
131
|
}
|
|
@@ -127,7 +156,7 @@ export function createCliRunReport(modelInput) {
|
|
|
127
156
|
}),
|
|
128
157
|
};
|
|
129
158
|
}
|
|
130
|
-
export function createCliAggregateRunReport({ suiteName, testName, startedAtIso, durationMs, childModels, }) {
|
|
159
|
+
export function createCliAggregateRunReport({ suiteName, testName, startedAtIso, durationMs, childModels, deviceInfo = undefined, }) {
|
|
131
160
|
const childTests = childModels.map((model) => ({
|
|
132
161
|
testName: model.testName ?? "Test",
|
|
133
162
|
durationSeconds: model.durationSeconds,
|
|
@@ -162,7 +191,7 @@ export function createCliAggregateRunReport({ suiteName, testName, startedAtIso,
|
|
|
162
191
|
skipped,
|
|
163
192
|
status,
|
|
164
193
|
}),
|
|
165
|
-
events:
|
|
194
|
+
events: deviceReportEvents(deviceInfo, startedAtIso, testName),
|
|
166
195
|
childTests,
|
|
167
196
|
warnings: [],
|
|
168
197
|
});
|
|
@@ -183,7 +212,7 @@ export function renderCliCloudReport(model, savedRunId) {
|
|
|
183
212
|
for (const child of cloudModel.childTests ?? []) {
|
|
184
213
|
replaceScreenshots(child.events);
|
|
185
214
|
}
|
|
186
|
-
return renderHtmlReport(cloudModel, { includeScripts: false });
|
|
215
|
+
return renderHtmlReport(cloudModel, { includeScripts: false, showRunDetails: false });
|
|
187
216
|
}
|
|
188
217
|
export async function writeCliRunReportArtifact(artifact, { reportPath, cwd = process.cwd(), isBatch = false } = {}) {
|
|
189
218
|
const outputPath = resolveReportOutputPath({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import path from "path";
|
|
2
3
|
import { mkdir, writeFile } from "fs/promises";
|
|
3
4
|
import { getNetworkCaptureContext, navigateToUrl as navigateBrowserToUrl, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "../browser/connection.js";
|
|
@@ -9,6 +10,7 @@ import { buildExecutionModePrompt } from "../core/prompts.js";
|
|
|
9
10
|
import { addOpenAiResponseToDroidCuaRunUsage, createDroidCuaRunUsageTotals, estimateDroidCuaRunCostUsd, setDroidCuaRunUsageModel, } from "../core/droid-cua-run-usage.js";
|
|
10
11
|
import { getDroidVersion } from "../core/droid-version.js";
|
|
11
12
|
import { ExecutionMode } from "../modes/execution-mode.js";
|
|
13
|
+
import { STEP_WATCHDOG_MODEL } from "../core/step-watchdog.js";
|
|
12
14
|
import { buildDroidRunConfiguration, reportDroidRunCompletion, } from "../integrations/loadmill/run-reporter.js";
|
|
13
15
|
import { droidReportUploadManager } from "../integrations/loadmill/report-upload-manager.js";
|
|
14
16
|
import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-cua-segment.js";
|
|
@@ -28,10 +30,6 @@ function sanitizePathSegment(value) {
|
|
|
28
30
|
.replace(/-+/g, "-")
|
|
29
31
|
.replace(/^[-_.]+|[-_.]+$/g, "") || "test";
|
|
30
32
|
}
|
|
31
|
-
function createHeadlessRunId(index = 0) {
|
|
32
|
-
const suffix = index > 0 ? `-${index + 1}` : "";
|
|
33
|
-
return `run-${Date.now()}${suffix}`;
|
|
34
|
-
}
|
|
35
33
|
function networkLogPath(runId, report) {
|
|
36
34
|
if (!report?.enabled)
|
|
37
35
|
return path.resolve("logs", `network-${runId}.jsonl`);
|
|
@@ -73,14 +71,14 @@ function getRecordScreenshotDirForTest(baseScreenshotDir, testName, isBatchRun)
|
|
|
73
71
|
}
|
|
74
72
|
return path.join(baseScreenshotDir, sanitizePathSegment(testName));
|
|
75
73
|
}
|
|
76
|
-
function toDroidCuaSegmentRunStatus(success, error = null) {
|
|
74
|
+
function toDroidCuaSegmentRunStatus(success, error = null, failureKind = null) {
|
|
77
75
|
if (success) {
|
|
78
76
|
return "pass";
|
|
79
77
|
}
|
|
80
78
|
if (error === "Stopped by user") {
|
|
81
79
|
return "skipped";
|
|
82
80
|
}
|
|
83
|
-
if (typeof error === "string" && error.startsWith("Assertion failed:")) {
|
|
81
|
+
if (failureKind === "step_watchdog" || (typeof error === "string" && error.startsWith("Assertion failed:"))) {
|
|
84
82
|
return "failure";
|
|
85
83
|
}
|
|
86
84
|
return "error";
|
|
@@ -179,7 +177,7 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
|
|
|
179
177
|
}
|
|
180
178
|
return engineOptions;
|
|
181
179
|
}
|
|
182
|
-
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
|
|
180
|
+
async function runHeadlessTest({ runId, captureIndex = 0, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
|
|
183
181
|
let executionMode = null;
|
|
184
182
|
let captureStarted = false;
|
|
185
183
|
const finishCapture = async () => {
|
|
@@ -201,12 +199,13 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
201
199
|
}
|
|
202
200
|
};
|
|
203
201
|
let loadmillSessionUser = null;
|
|
202
|
+
let stepWatchdogUsage = createDroidCuaRunUsageTotals({ model: STEP_WATCHDOG_MODEL });
|
|
204
203
|
let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
|
|
205
204
|
const buildSegmentUsageProperties = () => ({
|
|
206
205
|
model: runUsageTotals.model,
|
|
207
206
|
llmProviderMode: runtimeContext.config.llmProviderMode,
|
|
208
|
-
estimatedCostUsd: estimateDroidCuaRunCostUsd(runUsageTotals),
|
|
209
|
-
tokensUsed: runUsageTotals.totalTokens,
|
|
207
|
+
estimatedCostUsd: estimateDroidCuaRunCostUsd(runUsageTotals, stepWatchdogUsage),
|
|
208
|
+
tokensUsed: runUsageTotals.totalTokens + stepWatchdogUsage.totalTokens,
|
|
210
209
|
});
|
|
211
210
|
const reportSegmentRunCompleted = async (input) => {
|
|
212
211
|
await reportDroidCuaCiTestRunCompleted({
|
|
@@ -288,7 +287,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
288
287
|
if (networkCapture) {
|
|
289
288
|
try {
|
|
290
289
|
const browserContext = isBrowserTarget ? getNetworkCaptureContext(session.deviceId) : null;
|
|
291
|
-
const capturePath = await networkCapture.startCapture(networkLogPath(runId, report), { browserContext });
|
|
290
|
+
const capturePath = await networkCapture.startCapture(networkLogPath(isBatchRun ? `${runId}-${captureIndex + 1}` : runId, report), { browserContext });
|
|
292
291
|
console.log(`Network log: ${capturePath}`);
|
|
293
292
|
captureStarted = true;
|
|
294
293
|
engine.networkQuery = networkCapture.query;
|
|
@@ -309,6 +308,10 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
309
308
|
loadmillClient: runtimeContext.loadmillClient,
|
|
310
309
|
runtimeConfig: runtimeContext.config,
|
|
311
310
|
runtimeCredentials: runtimeContext.credentials,
|
|
311
|
+
testDataContext,
|
|
312
|
+
onStepWatchdogResponse: (response) => {
|
|
313
|
+
stepWatchdogUsage = addOpenAiResponseToDroidCuaRunUsage(stepWatchdogUsage, response);
|
|
314
|
+
},
|
|
312
315
|
// Inline screenshots as data URLs so the HTML report is self-contained.
|
|
313
316
|
...(reportEnabled ? { includeScreenshotDataUrl: true } : {}),
|
|
314
317
|
});
|
|
@@ -318,6 +321,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
318
321
|
await headlessDebug.endExecutionSession(runId, {
|
|
319
322
|
success: Boolean(result.success),
|
|
320
323
|
error: result.error ?? null,
|
|
324
|
+
failureKind: result.failureKind,
|
|
321
325
|
durationMs,
|
|
322
326
|
instructionsTotal: instructions.length,
|
|
323
327
|
instructionsCompleted: stats.instructionsCompleted ?? 0,
|
|
@@ -327,7 +331,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
327
331
|
retries: stats.retryCount ?? 0
|
|
328
332
|
});
|
|
329
333
|
await reportSegmentRunCompleted({
|
|
330
|
-
status: toDroidCuaSegmentRunStatus(Boolean(result.success), result.error ?? null),
|
|
334
|
+
status: toDroidCuaSegmentRunStatus(Boolean(result.success), result.error ?? null, result.failureKind),
|
|
331
335
|
durationMs,
|
|
332
336
|
numOfSteps: instructions.length,
|
|
333
337
|
});
|
|
@@ -338,6 +342,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
338
342
|
loadmillClient: runtimeContext.loadmillClient,
|
|
339
343
|
source: "ci",
|
|
340
344
|
runId,
|
|
345
|
+
outcome: toDroidCuaSegmentRunStatus(Boolean(result.success), result.error ?? null, result.failureKind),
|
|
341
346
|
projectName: path.basename(path.dirname(testPath)) || null,
|
|
342
347
|
testName,
|
|
343
348
|
runType: "test",
|
|
@@ -363,12 +368,17 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
363
368
|
console.error(`\nTest failed: ${result.error}`);
|
|
364
369
|
}
|
|
365
370
|
const reportInput = {
|
|
371
|
+
deviceInfo,
|
|
366
372
|
suiteName: path.basename(path.dirname(testPath)) || null,
|
|
367
373
|
testName,
|
|
368
374
|
startedAtIso: stats.startTime ? new Date(stats.startTime).toISOString() : null,
|
|
369
375
|
durationMs,
|
|
370
376
|
success: Boolean(result.success),
|
|
371
377
|
error: result.error ?? null,
|
|
378
|
+
failureKind: result.failureKind,
|
|
379
|
+
...(runtimeContext.config.stepWatchdogMode !== "off" && runtimeContext.config.stepWatchdogMode
|
|
380
|
+
? { stepWatchdogMode: runtimeContext.config.stepWatchdogMode, stepWatchdogUsage }
|
|
381
|
+
: {}),
|
|
372
382
|
instructionsTotal: instructions.length,
|
|
373
383
|
stats,
|
|
374
384
|
};
|
|
@@ -465,6 +475,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
465
475
|
});
|
|
466
476
|
}
|
|
467
477
|
const failedReportInput = {
|
|
478
|
+
deviceInfo,
|
|
468
479
|
suiteName: path.basename(path.dirname(testPath)) || null,
|
|
469
480
|
testName,
|
|
470
481
|
startedAtIso: stats.startTime ? new Date(stats.startTime).toISOString() : null,
|
|
@@ -518,7 +529,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
518
529
|
throw error;
|
|
519
530
|
}
|
|
520
531
|
}
|
|
521
|
-
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget = false, desktopController = null, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
|
|
532
|
+
export async function runHeadlessInstructionRuns({ runId = randomUUID(), runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget = false, desktopController = null, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
|
|
522
533
|
await clearLoadmillReportMetadata(report?.metadataPath);
|
|
523
534
|
const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
|
|
524
535
|
const runConfiguration = buildDroidRunConfiguration({
|
|
@@ -560,7 +571,6 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
560
571
|
const aggregateLocalReportModels = [];
|
|
561
572
|
const aggregateReportModels = [];
|
|
562
573
|
for (const [index, run] of runs.entries()) {
|
|
563
|
-
const runId = createHeadlessRunId(index);
|
|
564
574
|
const testScreenshotDir = getRecordScreenshotDirForTest(screenshotDir, run.testName, isBatchRun);
|
|
565
575
|
if (isBatchRun) {
|
|
566
576
|
console.log(`\n[${index + 1}/${runs.length}] ${run.testName}`);
|
|
@@ -572,6 +582,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
572
582
|
}
|
|
573
583
|
const result = await runHeadlessTestImpl({
|
|
574
584
|
runId,
|
|
585
|
+
captureIndex: index,
|
|
575
586
|
testPath: run.testPath,
|
|
576
587
|
testName: run.testName,
|
|
577
588
|
instructions,
|
|
@@ -625,6 +636,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
625
636
|
const message = error instanceof Error ? error.message : String(error);
|
|
626
637
|
if (instructionsTarget?.type === "selection" && (report?.enabled || cloudReportEnabled)) {
|
|
627
638
|
const failedReportModel = buildCliRunReportModel({
|
|
639
|
+
deviceInfo,
|
|
628
640
|
suiteName: path.basename(path.dirname(run.testPath)) || null,
|
|
629
641
|
testName: run.testName,
|
|
630
642
|
startedAtIso: new Date().toISOString(),
|
|
@@ -670,6 +682,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
670
682
|
: path.basename(instructionsTarget.path);
|
|
671
683
|
if (isSelection && report?.enabled && aggregateLocalReportModels.length > 0) {
|
|
672
684
|
const aggregateReport = createCliAggregateRunReport({
|
|
685
|
+
deviceInfo,
|
|
673
686
|
suiteName: projectName,
|
|
674
687
|
testName,
|
|
675
688
|
startedAtIso: new Date(batchStartedAt).toISOString(),
|
|
@@ -691,7 +704,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
691
704
|
...runConfiguration,
|
|
692
705
|
loadmillClient: runtimeContext.loadmillClient,
|
|
693
706
|
source: "ci",
|
|
694
|
-
runId
|
|
707
|
+
runId,
|
|
695
708
|
projectName,
|
|
696
709
|
testName,
|
|
697
710
|
runType: "project",
|
|
@@ -715,6 +728,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
715
728
|
});
|
|
716
729
|
if (cloudUploadSession && reportedRun.savedRunId) {
|
|
717
730
|
const aggregateReport = createCliAggregateRunReport({
|
|
731
|
+
deviceInfo,
|
|
718
732
|
suiteName: projectName,
|
|
719
733
|
testName,
|
|
720
734
|
startedAtIso: new Date(batchStartedAt).toISOString(),
|
|
@@ -56,6 +56,7 @@ export async function handleHelp(args, session, context) {
|
|
|
56
56
|
addOutput({ type: 'info', text: ' --execution-prompt-file Headless execution prompt customization file' });
|
|
57
57
|
addOutput({ type: 'info', text: ' --record Record screenshots during execution' });
|
|
58
58
|
addOutput({ type: 'info', text: ' --explain Explain non-obvious decisions and completion evidence' });
|
|
59
|
+
addOutput({ type: 'info', text: ' --step-watchdog <off|on> Check for failures after each instruction: off (default), on' });
|
|
59
60
|
addOutput({ type: 'info', text: ' --report [path] Save a self-contained HTML report locally' });
|
|
60
61
|
addOutput({ type: 'info', text: ' --network-proxy Forward local mobile traffic through port 8765 without capture (all Mac addresses for iPhones and Android phones)' });
|
|
61
62
|
addOutput({ type: 'info', text: ' --capture-network Capture local mobile or browser test traffic to one JSONL log per test' });
|
|
@@ -23,6 +23,7 @@ export async function handleStop(args, session, context) {
|
|
|
23
23
|
// Set flag to stop execution
|
|
24
24
|
if (context.activeExecutionMode) {
|
|
25
25
|
context.activeExecutionMode.shouldStop = true;
|
|
26
|
+
context.activeExecutionMode.requestStop?.();
|
|
26
27
|
}
|
|
27
28
|
return true; // Continue loop
|
|
28
29
|
}
|
|
@@ -20,15 +20,19 @@ export const CAPTURE_SOURCES = Object.freeze({
|
|
|
20
20
|
CALL_OUTPUT_EXPLICIT_ACTION: "call-output-explicit-action",
|
|
21
21
|
INSTRUCTION_INPUT: "instruction-input",
|
|
22
22
|
INSTRUCTION_INPUT_ASSERTION: "instruction-input-assertion",
|
|
23
|
+
STEP_WATCHDOG: "step-watchdog",
|
|
24
|
+
STEP_FAILURE: "step-failure",
|
|
23
25
|
DESIGN_INPUT: "design-input",
|
|
24
26
|
});
|
|
25
27
|
const HIDDEN_DEBUG_MEDIA_KINDS = new Set([
|
|
26
28
|
CAPTURE_SOURCES.PREVIEW_CLICK,
|
|
29
|
+
CAPTURE_SOURCES.STEP_WATCHDOG,
|
|
27
30
|
]);
|
|
28
31
|
const CLICK_ANNOTATION_MEDIA_KINDS = new Set([
|
|
29
32
|
CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
|
|
30
33
|
]);
|
|
31
34
|
const SCREENSHOT_MEDIA_KINDS = new Set([
|
|
35
|
+
CAPTURE_SOURCES.STEP_FAILURE,
|
|
32
36
|
CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
|
|
33
37
|
CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION,
|
|
34
38
|
CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION,
|
|
@@ -61,6 +61,7 @@ export function normalizeDroidCuaCostModel(model) {
|
|
|
61
61
|
}
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
|
+
/** @param {{ model?: string | null }} [options] */
|
|
64
65
|
export function createDroidCuaRunUsageTotals({ model = null } = {}) {
|
|
65
66
|
const normalizedModel = trimToNull(model);
|
|
66
67
|
return {
|
|
@@ -110,7 +111,11 @@ export function addOpenAiResponseToDroidCuaRunUsage(totals, response) {
|
|
|
110
111
|
totalTokens: totals.totalTokens + totalTokens,
|
|
111
112
|
};
|
|
112
113
|
}
|
|
113
|
-
|
|
114
|
+
/**
|
|
115
|
+
* @param {ReturnType<typeof createDroidCuaRunUsageTotals>} totals
|
|
116
|
+
* @param {{ inputTokens: number } | null} [stepWatchdogUsage]
|
|
117
|
+
*/
|
|
118
|
+
export function estimateDroidCuaRunCostUsd(totals, stepWatchdogUsage = null) {
|
|
114
119
|
const costModel = totals.costModel ?? normalizeDroidCuaCostModel(totals.model);
|
|
115
120
|
if (!costModel) {
|
|
116
121
|
return null;
|
|
@@ -119,6 +124,8 @@ export function estimateDroidCuaRunCostUsd(totals) {
|
|
|
119
124
|
if (!pricing) {
|
|
120
125
|
return null;
|
|
121
126
|
}
|
|
122
|
-
|
|
127
|
+
// Decisions charges input tokens only; keep its usage separate from the actor model.
|
|
128
|
+
const cost = (totals.inputTokens / TOKENS_PER_MILLION) * pricing.inputPerMillionUsd
|
|
129
|
+
+ (finiteNumber(stepWatchdogUsage?.inputTokens) / TOKENS_PER_MILLION) * 0.1;
|
|
123
130
|
return Number(cost.toFixed(8));
|
|
124
131
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
const executionRunId = new AsyncLocalStorage();
|
|
3
|
+
export function withExecutionRunId(runId, callback) {
|
|
4
|
+
return executionRunId.run(runId, callback);
|
|
5
|
+
}
|
|
6
|
+
export function getExecutionRunId() {
|
|
7
|
+
return executionRunId.getStore();
|
|
8
|
+
}
|