@loadmill/droid-cua 2.40.0 → 2.42.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 +33 -3
  2. package/build/index.js +69 -10
  3. package/build/src/browser/connection.js +5 -0
  4. package/build/src/browser/factory.js +1 -0
  5. package/build/src/browser/playwright/connection.js +3 -0
  6. package/build/src/cli/headless-runner.js +55 -3
  7. package/build/src/cli/headless-target.js +15 -7
  8. package/build/src/commands/help.js +10 -4
  9. package/build/src/core/execution-engine.js +26 -1
  10. package/build/src/core/report/render-html-report.js +36 -6
  11. package/build/src/core/report/run-log-model.js +65 -0
  12. package/build/src/core/report/run-report-model.js +1 -0
  13. package/build/src/core/report/run-report-sections.js +15 -0
  14. package/build/src/core/tool-handlers/assert.js +45 -2
  15. package/build/src/core/tool-handlers/query-network.js +92 -0
  16. package/build/src/desktop/computer-use.js +137 -51
  17. package/build/src/desktop/primary-display.js +74 -0
  18. package/build/src/device/openai.js +25 -0
  19. package/build/src/modes/execution-mode.js +10 -1
  20. package/build/src/network/browser-capture.js +110 -0
  21. package/build/src/network/certificate-authority.js +205 -0
  22. package/build/src/network/certificate-install.js +242 -0
  23. package/build/src/network/local-proxy.js +287 -0
  24. package/build/src/network/network-capture.js +243 -0
  25. package/build/src/network/query-network.js +215 -0
  26. package/build/src/network/run-network-capture.js +107 -0
  27. package/build/src/prompts/base.js +9 -6
  28. package/build/src/prompts/execution.js +10 -6
  29. package/package.json +2 -1
  30. package/skill/droid-cua/SKILL.md +1 -1
  31. package/skill/droid-cua/references/authoring-tests.md +16 -0
  32. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +10 -0
  33. package/skill/droid-cua/references/running-and-healing.md +9 -1
package/README.md CHANGED
@@ -97,6 +97,7 @@ Desktop app support:
97
97
  - **Desktop-first workflow** - Create, run, and manage tests from one app
98
98
  - **Setup guidance** - Configure API access and platform prerequisites in the app
99
99
  - **Device and simulator connection** - Connect Android targets and iOS simulators
100
+ - **Network certificate preparation** - Manage one active root CA and install it on a connected local simulator or supported emulator from the desktop app's Tools menu
100
101
  - **Natural-language test creation** - Describe flows in plain English
101
102
  - **Test management** - Create, edit, save, and rerun reusable scripts, including tests organized in nested project folders
102
103
  - **Live execution logs** - Watch actions and progress as tests run
@@ -105,6 +106,22 @@ Desktop app support:
105
106
  - **Run video export** - Generate and download a polished MP4 replay from a desktop run's screenshots and instruction timeline
106
107
  - **Headless support** - Reuse scripts in CLI and automation workflows
107
108
 
109
+ **Network certificate preparation:** Use **Tools → 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 **Tools → Install Network Certificate**. 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. Android certificate setup supports capable rootable images through Android 16; Google Play images without root access and Android 17 are not supported.
110
+
111
+ **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.
112
+
113
+ **Mobile setup:** Manually route a prepared local simulator or emulator to `127.0.0.1:8765` (`10.0.2.2:8765` from Android Emulator). Enable **Network Capture** in desktop Settings to keep the proxy forwarding while Droid is open and automatically capture every local mobile test, including each project or scenario case. Existing **Network Proxy** settings do not turn capture on; opt in to the new setting. For CLI tests, pass `--capture-network`; this starts the proxy and writes one JSONL log per test. `--network-proxy` remains a forwarding-only CLI flag. Droid prints or logs each capture path and a best-effort health summary. Desktop artifacts are in the project's results folder; CLI artifacts are beside `--report` output when enabled, or under `logs/`. A missing CA stops mobile capture before test actions. No observed traffic or failed HTTPS decryption is reported separately from test assertions, but capture health does not prove that all traffic was observed. WebSocket messages are unsupported. Manual proxy setup is the V1 workflow. Droid does not change device or Mac proxy settings; turn off manually configured routing when finished. If Droid exits while routing remains enabled, restart Droid or clear the proxy to restore connectivity.
114
+
115
+ Captures stop recording at 100 MiB per run, retaining a partial JSONL with a `CAPTURE_LIMIT` error. Traffic continues forwarding; live network queries report incomplete evidence. Captured bodies are limited to 1 MiB, both on the wire and after decompression. Compressed bodies that expand beyond that limit are omitted with `decoded-body-too-large`; this is incomplete evidence, not a missing response. The separate query evidence limit is 1,000,000 serialized characters. A capture limit or missing traffic is not proof that an application request never occurred.
116
+
117
+ For troubleshooting, “No traffic observed” points to routing or an app that did not send traffic; it does not prove an expected request was absent. HTTPS connections with no decrypted requests point to certificate trust or interception compatibility. A port-in-use error means another Droid instance or proxy already owns the listener. Stop that listener before starting the CLI. A capture-size or write failure retains only partial evidence. iOS network validation is deferred; ordinary iOS runs without capture do not require proxy or certificate preparation.
118
+
119
+ **Network questions and assertions:** With capture enabled, tests can ask Droid to inspect HTTP traffic and compare values across requests in natural language. For example: “Assert that the POST to `/pageview` after opening Home contains the `item_id` returned by `/home`.” The `query_network` tool searches by URL substring, method, status, flow IDs, request timestamps, or a starting step. It considers all matching exchanges in time order and sends only selected headers/bodies to one side call using the selected run model. Large results require a narrower search; they are not silently truncated. Optional observation windows wait the full duration, up to 30 seconds per query. Absence means only “no matching traffic observed during this window”; unavailable capture or insufficient evidence cannot establish absence. Desktop and HTML reports show one collapsed Network query card per call; expand it to see the question and answer. The agent receives structured flow references and capture diagnostics, while model details stay out of the timeline.
120
+
121
+ To inspect a saved capture without a model, run `node scripts/query-network.mjs path/to/network.jsonl '{"url_contains":"/pageview","method":"POST","sections":["request_body"]}'` from the repository. Select `request_headers`, `response_headers`, `request_body`, or `response_body`; use `sections: []` for metadata. Saved-log queries accept `after` and `before` timestamps, but not live step IDs or observation waits.
122
+
123
+ **Developer network capture:** `node scripts/network-capture-smoke.mjs` still offers a standalone proxy checkpoint. Leave desktop **Network Capture** off and omit CLI network flags while it owns `127.0.0.1:8765`. Press Ctrl+C when finished; use `--output path.jsonl` to choose the JSONL path. Request and response headers are written immediately, body records when streams finish or abort, binary bodies are omitted, and captured bodies are limited to 1 MiB on the wire and after decompression. Filtering is partial: JSONL captures may contain credentials, tokens, cookies, or other sensitive data. Treat them as private run artifacts.
124
+
108
125
  ---
109
126
 
110
127
  <h2 id="how-it-works">🔧 How It Works</h2>
@@ -193,6 +210,9 @@ droid-cua run tests/login.dcua --platform ios --avd "iPhone 16"
193
210
  # List running desktop applications and their window IDs (macOS or Windows)
194
211
  droid-cua --target desktop --list-desktop-apps
195
212
 
213
+ # Control the full primary display across applications
214
+ droid-cua --target desktop --instructions tests/launcher.dcua
215
+
196
216
  # Control one already-running desktop application window
197
217
  droid-cua --target desktop --desktop-app com.apple.TextEdit --desktop-window 123 --instructions tests/editor.dcua
198
218
 
@@ -276,8 +296,8 @@ Supported CLI options include:
276
296
  - `--list-devices`
277
297
  - `--browser`
278
298
  - `--target` (`mobile`, `web`, or `desktop`)
279
- - `--desktop-app` (desktop application ID or exact name)
280
- - `--desktop-window` (required when the application has multiple visible windows)
299
+ - `--desktop-app` (limit desktop control to an application ID or exact name; omit for full desktop)
300
+ - `--desktop-window` (select a window within `--desktop-app`)
281
301
  - `--desktop-input` (`physical` by default; use `accessibility` for cursor-free input)
282
302
  - `--list-desktop-apps`
283
303
  - `--instructions`
@@ -294,6 +314,8 @@ Supported CLI options include:
294
314
  - `--execution-prompt-file`
295
315
  - `--secrets`
296
316
  - `--record`
317
+ - `--network-proxy` (local mobile sessions only; starts idle forwarding at `127.0.0.1:8765`)
318
+ - `--capture-network` (local mobile and Chrome/Edge test runs; writes a JSONL log for each test, using a proxy only for mobile)
297
319
  - `--report`
298
320
  - `--report-metadata <path>`
299
321
  - `--no-cloud-report`
@@ -304,7 +326,15 @@ Supported CLI options include:
304
326
  `OPENAI_API_KEY`. Saved direct OpenAI selections of GPT-5.6 Terra, GPT-5.6 Luna,
305
327
  or GPT-5.4 move to GPT-6 Sol, GPT-6 Luna, or GPT-6 Sol, respectively.
306
328
 
307
- Desktop control supports macOS and Windows. In the desktop app, enable **Experimental desktop control** at the bottom of Settings to show Desktop in the platform picker. Desktop runs use visual input by default: Droid shows a system notice for three seconds before taking control, brings the selected window to the foreground, then sends physical pointer and keyboard input at the model's coordinates. The three-second delay is fixed; once the test starts, use Stop in Droid to end it. Avoid using the mouse or keyboard during the run. When the run ends, Droid leaves the target window in front and shows another system notice that control has finished, including when the run fails or is stopped. CLI runs also leave the target in front. Use `--desktop-input accessibility` for cursor-free CLI input instead. macOS requires Screen & System Audio Recording and Accessibility permissions for Droid or the terminal running the CLI. Windows targets must run at the same integrity level as Droid; a non-elevated Droid process cannot inject input into an elevated application. If Windows returns an all-black window capture, Droid brings the target back to the foreground and crops the visible desktop to the window's visible frame, excluding its invisible resize border. The crop is resized only after extraction to preserve window detail. The fallback logs a warning and requires the target to remain unobstructed on the primary display; off-screen portions appear black. With accessibility input, bring the target to the foreground yourself when requested.
329
+ Desktop control supports macOS and Windows. In the desktop app, enable **Experimental desktop control** at the bottom of Settings to show Desktop in the platform picker. Desktop runs use physical pointer and keyboard input by default. Droid shows a system notice for three seconds before taking control and a completion notice when the run finishes, fails, or is stopped. The delay is fixed; use Stop in Droid once the run starts. Avoid using the mouse or keyboard during the run. macOS requires Screen & System Audio Recording and Accessibility permissions for Droid or the terminal running the CLI. On Windows, Droid must run at the same integrity level as the target application. System notices and automatic minimization apply only to runs started in the desktop app.
330
+
331
+ CLI runs with `--target desktop` control the full primary display by default, with no application flag needed. In the desktop app, choose **Full desktop**, the first option in the Application dropdown. This mode captures and controls the primary display across applications, including apps launched during the test; no target application needs to be running when connecting. Keep all test windows on the primary display. Full desktop requires physical input. Droid minimizes itself after the three-second notice and remains minimized when the run ends; open Droid to review the result or use Stop. Existing saved application selections are preserved.
332
+
333
+ Use `--desktop-app <id-or-exact-name>` or select an application in Droid to limit control to that application's windows. Application mode brings the selected window forward and leaves it there when the run ends. Add `--desktop-window <id>` to select a particular window, or `--desktop-input accessibility` for cursor-free CLI input; both options require `--desktop-app`. Before each run and screenshot, Droid refreshes window selection within the original application so it can follow a recreated login window. If replacement is ambiguous, select a window again. If a captured window disappears or moves before input, Droid requests a fresh screenshot instead of transferring its coordinates to a replacement. Focus errors report the activation reason; they do not establish an elevation problem.
334
+
335
+ If Windows returns an all-black window capture, Droid brings the target forward and crops the visible desktop to the window's visible frame, excluding its invisible resize border. The fallback logs a warning, crops before resizing to preserve detail, and requires the target to remain unobstructed on the primary display; off-screen portions appear black. With accessibility input, bring the target forward yourself when requested. Full desktop reports capture failures directly, without this window capture fallback.
336
+
337
+ Desktop **Test design** uses the same notices once you submit your instructions and exploration starts. Full desktop minimizes Droid at that point. Droid sends the completion notice when the generated test is ready, exploration is stopped, or recovery ends in an error. It does not automatically bring itself forward; open Droid whenever you need to give guidance, use Stop, or review the generated test. Opening Test design alone does not take desktop control. When a controller rejects an action, Droid skips the remaining actions in that batch and asks the model to continue from a fresh screenshot with the failure reason. Rejected actions are not counted as completed.
308
338
 
309
339
  The system notices are shown only for desktop control runs started in the desktop app. On macOS, they may not appear in the unsigned Electron dev app; verify them in a signed packaged build.
310
340
 
package/build/index.js CHANGED
@@ -3,7 +3,7 @@ import minimist from "minimist";
3
3
  import dotenv from "dotenv";
4
4
  import path from "path";
5
5
  import { mkdir } from "fs/promises";
6
- import { connectToDevice, disconnect as disconnectDevice, getDeviceInfo } from "./src/device/connection.js";
6
+ import { connectToDevice, disconnect as disconnectDevice, getDeviceInfo, getCurrentPlatform as getConnectedDevicePlatform } from "./src/device/connection.js";
7
7
  import { connectToBrowser, disconnect as disconnectBrowser, getBrowserInfo, } from "./src/browser/connection.js";
8
8
  import { setBrowserBackendId } from "./src/browser/factory.js";
9
9
  import { normalizeBrowserInfoForSession } from "./src/browser/normalize.js";
@@ -45,9 +45,14 @@ import { ARTIFACT_READY_STATUSES } from "./src/device/cloud/loadmill-cloud/run-s
45
45
  import { formatDuration } from "./src/core/report/run-log-model.js";
46
46
  import { createBrowserSessionTeardown, withLoadmillCloudSession } from "./src/cli/loadmill-cloud-session.js";
47
47
  import { createDesktopController, listDesktopApplications } from "./src/desktop/computer-use.js";
48
+ import { createRunNetworkCapture } from "./src/network/run-network-capture.js";
49
+ import { loadCertificateAuthority } from "./src/network/certificate-authority.js";
50
+ import { ensureCliAndroidCertificate, inspectNetworkDevice } from "./src/network/certificate-install.js";
48
51
  dotenv.config();
52
+ let networkCapture = null;
49
53
  process.once("SIGTERM", async () => {
50
54
  process.exitCode = 143;
55
+ await networkCapture?.shutdown().catch(() => { });
51
56
  // Registering a SIGTERM listener cancels Node's default terminate, so exit
52
57
  // explicitly; await the /stop because a bare fire-and-forget fetch may not
53
58
  // dispatch before exit. Capped so a slow server can't hang the exit.
@@ -386,6 +391,36 @@ async function main() {
386
391
  if (!isHeadlessInstructionsRun && args["test-data"] != null) {
387
392
  throw new Error("--test-data requires `droid-cua run <path>` or --instructions headless execution.");
388
393
  }
394
+ if (args["network-proxy"] != null && args["network-proxy"] !== true) {
395
+ throw new Error("--network-proxy is a flag and does not take a value.");
396
+ }
397
+ if (args["network-proxy"] === true && (isBrowserTarget || isDesktopTarget || isCloudTarget)) {
398
+ throw new Error("--network-proxy is available only for local mobile sessions.");
399
+ }
400
+ if (args["capture-network"] != null && args["capture-network"] !== true) {
401
+ throw new Error("--capture-network is a flag and does not take a value.");
402
+ }
403
+ if (args["capture-network"] === true && (!isHeadlessInstructionsRun || isDesktopTarget || isCloudTarget)) {
404
+ throw new Error("--capture-network requires a local mobile or browser test run.");
405
+ }
406
+ const captureNetwork = args["capture-network"] === true;
407
+ if (captureNetwork && !isBrowserTarget)
408
+ await loadCertificateAuthority();
409
+ networkCapture = args["network-proxy"] === true || captureNetwork ? createRunNetworkCapture() : null;
410
+ if (captureNetwork) {
411
+ process.once("SIGINT", async () => {
412
+ await networkCapture?.shutdown().catch(() => { });
413
+ process.exit(130);
414
+ });
415
+ }
416
+ if (networkCapture && !isBrowserTarget) {
417
+ try {
418
+ await networkCapture.startProxy();
419
+ }
420
+ catch (error) {
421
+ throw new Error(`Droid network proxy could not start: ${error.message}`, { cause: error });
422
+ }
423
+ }
389
424
  let preloadedHeadlessConfig = null;
390
425
  let preloadedHeadlessTarget = null;
391
426
  let headlessInstructionRuns = [];
@@ -520,7 +555,7 @@ async function main() {
520
555
  }
521
556
  else if (isBrowserTarget) {
522
557
  targetId = await connectToBrowser({
523
- url: startupUrl,
558
+ url: captureNetwork ? undefined : startupUrl,
524
559
  headless: true,
525
560
  browserFamily: targetConfig.browserFamily,
526
561
  sessionMode: targetConfig.sessionMode
@@ -534,6 +569,7 @@ async function main() {
534
569
  }
535
570
  else if (isDesktopTarget) {
536
571
  desktopController = await createDesktopController({
572
+ scope: targetConfig.desktopScope,
537
573
  app: targetConfig.desktopApp,
538
574
  windowId: targetConfig.desktopWindow,
539
575
  inputMode: targetConfig.desktopInput,
@@ -542,8 +578,8 @@ async function main() {
542
578
  });
543
579
  targetId = desktopController.deviceId;
544
580
  deviceInfo = desktopController.deviceInfo;
545
- console.log(`Attached to desktop window: ${deviceInfo.device_name}`);
546
- console.log(`Model sees window: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
581
+ console.log(`Attached to desktop target: ${deviceInfo.device_name}`);
582
+ console.log(`Model sees desktop target: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
547
583
  console.log(`Desktop input: ${deviceInfo.desktopInputMode === "physical" ? "physical OS pointer" : "cursor-free accessibility"}`);
548
584
  process.once("SIGINT", async () => {
549
585
  console.log("\nStopping desktop run...");
@@ -766,6 +802,20 @@ async function main() {
766
802
  }
767
803
  else {
768
804
  targetId = await connectToDevice(avdName, platform);
805
+ if (captureNetwork) {
806
+ const connectedPlatform = getConnectedDevicePlatform();
807
+ if (connectedPlatform === "android") {
808
+ if (!/^emulator-\d+$/.test(targetId))
809
+ throw new Error("--capture-network supports Android Emulators, not physical Android devices.");
810
+ const ca = await loadCertificateAuthority();
811
+ const trust = await ensureCliAndroidCertificate(targetId, ca);
812
+ if (trust.changed)
813
+ console.log(`Reapplied Droid's network certificate on ${targetId}.`);
814
+ }
815
+ else {
816
+ await inspectNetworkDevice({ platform: "ios", deviceId: targetId });
817
+ }
818
+ }
769
819
  deviceInfo = await getDeviceInfo(targetId);
770
820
  console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
771
821
  console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
@@ -836,12 +886,15 @@ async function main() {
836
886
  desktopController,
837
887
  isCloudTarget,
838
888
  runTarget: { isBrowserTarget, isDesktopTarget, isCloudTarget },
839
- report: reportConfig
889
+ report: reportConfig,
890
+ networkCapture: captureNetwork ? networkCapture : null,
891
+ initialBrowserUrl: captureNetwork && isBrowserTarget ? startupUrl : null
840
892
  });
841
893
  exitCode = result.exitCode;
842
894
  succeeded = true;
843
895
  }
844
896
  finally {
897
+ await networkCapture?.shutdown();
845
898
  await droidReportUploadManager.drain();
846
899
  if (lambdaTestSessionConnected) {
847
900
  console.log("Ending LambdaTest session...");
@@ -910,12 +963,18 @@ async function main() {
910
963
  reviseTestScript: runtimeContext.openAi.reviseTestScript,
911
964
  });
912
965
  // Otherwise, start interactive Ink shell
913
- await startInkShell(session, engine, {
914
- appContextPath: appContextPath ? path.resolve(appContextPath) : null,
915
- runtimeContext,
916
- });
966
+ try {
967
+ await startInkShell(session, engine, {
968
+ appContextPath: appContextPath ? path.resolve(appContextPath) : null,
969
+ runtimeContext,
970
+ });
971
+ }
972
+ finally {
973
+ await networkCapture?.shutdown();
974
+ }
917
975
  }
918
- main().catch((error) => {
976
+ main().catch(async (error) => {
977
+ await networkCapture?.shutdown().catch(() => { });
919
978
  const message = error instanceof Error ? error.message : String(error);
920
979
  console.error(`\nTest failed: ${message}`);
921
980
  process.exit(1);
@@ -35,6 +35,11 @@ export async function closeCurrentTab(browserSessionId) {
35
35
  return currentBackend.closeCurrentTab(browserSessionId);
36
36
  }
37
37
  export { getCurrentPlatform };
38
+ export function getNetworkCaptureContext(browserSessionId) {
39
+ if (!currentBackend?.getNetworkCaptureContext)
40
+ throw new Error("Network capture supports local Chrome and Edge sessions only.");
41
+ return currentBackend.getNetworkCaptureContext(browserSessionId);
42
+ }
38
43
  export async function disconnect() {
39
44
  if (currentBackend?.disconnect) {
40
45
  await currentBackend.disconnect();
@@ -16,6 +16,7 @@ function buildPlaywrightBackend() {
16
16
  closeCurrentTab: playwrightConnection.closeCurrentTab,
17
17
  handleModelAction: playwrightActions.handleModelAction,
18
18
  disconnect: playwrightConnection.disconnect,
19
+ getNetworkCaptureContext: playwrightConnection.getNetworkCaptureContext,
19
20
  };
20
21
  }
21
22
  function buildWebDriverBackend() {
@@ -373,6 +373,9 @@ export async function connectToBrowser(targetOrOptions) {
373
373
  });
374
374
  return sessionId;
375
375
  }
376
+ export function getNetworkCaptureContext(browserSessionId) {
377
+ return getActiveBrowserSession(browserSessionId).context;
378
+ }
376
379
  export async function getBrowserInfo(browserSessionId) {
377
380
  const session = getActiveBrowserSession(browserSessionId);
378
381
  const activePage = getActivePage(session);
@@ -1,6 +1,8 @@
1
1
  import path from "path";
2
2
  import { mkdir, writeFile } from "fs/promises";
3
- import { getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "../browser/connection.js";
3
+ import { getNetworkCaptureContext, navigateToUrl as navigateBrowserToUrl, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "../browser/connection.js";
4
+ import { normalizeBrowserInfoForSession } from "../browser/normalize.js";
5
+ import { updateBrowserSessionInfo } from "../core/tool-handlers/browser-session.js";
4
6
  import { handleModelAction as handleBrowserModelAction } from "../browser/actions.js";
5
7
  import { ExecutionEngine } from "../core/execution-engine.js";
6
8
  import { buildExecutionModePrompt } from "../core/prompts.js";
@@ -17,6 +19,7 @@ import { clearLoadmillReportMetadata } from "./loadmill-report-metadata.js";
17
19
  import { formatHeadlessTestSourceLabel } from "./headless-test-source-label.js";
18
20
  import { buildCliRunReportModel, createCapturingAddOutput, createCliAggregateRunReport, createCliRunReport, renderCliCloudReport, writeCliRunReportArtifact, } from "./headless-report.js";
19
21
  import { createRefuseReconnectConnectToBrowser } from "../desktop-shared/connection-predicates.js";
22
+ import { describeNetworkCapture, hasNetworkCaptureIssue } from "../network/run-network-capture.js";
20
23
  function sanitizePathSegment(value) {
21
24
  return String(value || "test")
22
25
  .replace(/\.dcua$/i, "")
@@ -29,6 +32,15 @@ function createHeadlessRunId(index = 0) {
29
32
  const suffix = index > 0 ? `-${index + 1}` : "";
30
33
  return `run-${Date.now()}${suffix}`;
31
34
  }
35
+ function networkLogPath(runId, report) {
36
+ if (!report?.enabled)
37
+ return path.resolve("logs", `network-${runId}.jsonl`);
38
+ if (!report.path)
39
+ return path.resolve(`network-${runId}.jsonl`);
40
+ const resolved = path.resolve(report.path);
41
+ const directory = /\.html?$/i.test(report.path) ? path.dirname(resolved) : resolved;
42
+ return path.join(directory, `network-${runId}.jsonl`);
43
+ }
32
44
  function logPendingScreenshotUploads(uploadManager, uploadSession, subject) {
33
45
  console.log('Verifying no pending upload exists');
34
46
  const pendingCount = uploadManager.getPendingCount?.(uploadSession) ?? 0;
@@ -167,8 +179,27 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
167
179
  }
168
180
  return engineOptions;
169
181
  }
170
- 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, }) {
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, }) {
171
183
  let executionMode = null;
184
+ let captureStarted = false;
185
+ const finishCapture = async () => {
186
+ if (!captureStarted)
187
+ return;
188
+ captureStarted = false;
189
+ try {
190
+ const result = await networkCapture.stopCapture();
191
+ if (result) {
192
+ const line = `${describeNetworkCapture(result)} Network log: ${result.path}`;
193
+ if (hasNetworkCaptureIssue(result))
194
+ console.warn(`Warning: ${line}`);
195
+ else
196
+ console.log(line);
197
+ }
198
+ }
199
+ catch (error) {
200
+ console.warn(`Warning: network capture could not finish: ${error.message}`);
201
+ }
202
+ };
172
203
  let loadmillSessionUser = null;
173
204
  let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
174
205
  const buildSegmentUsageProperties = () => ({
@@ -254,6 +285,23 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
254
285
  });
255
286
  const engine = new ExecutionEngine(session, engineOptions);
256
287
  executionMode = new ExecutionMode(session, engine, instructions, true);
288
+ if (networkCapture) {
289
+ try {
290
+ const browserContext = isBrowserTarget ? getNetworkCaptureContext(session.deviceId) : null;
291
+ const capturePath = await networkCapture.startCapture(networkLogPath(runId, report), { browserContext });
292
+ console.log(`Network log: ${capturePath}`);
293
+ captureStarted = true;
294
+ engine.networkQuery = networkCapture.query;
295
+ engine.toolCapabilities.queryNetwork = true;
296
+ }
297
+ catch (error) {
298
+ throw new Error(`Network capture setup failed: ${error.message}`, { cause: error });
299
+ }
300
+ }
301
+ if (initialBrowserUrl) {
302
+ await navigateBrowserToUrl(session.deviceId, initialBrowserUrl);
303
+ updateBrowserSessionInfo(session, normalizeBrowserInfoForSession(await getBrowserInfo(session.deviceId)));
304
+ }
257
305
  const result = await executionMode.execute({
258
306
  runId,
259
307
  addOutput,
@@ -264,6 +312,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
264
312
  // Inline screenshots as data URLs so the HTML report is self-contained.
265
313
  ...(reportEnabled ? { includeScreenshotDataUrl: true } : {}),
266
314
  });
315
+ await finishCapture();
267
316
  const stats = executionMode.stats || {};
268
317
  const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
269
318
  await headlessDebug.endExecutionSession(runId, {
@@ -363,6 +412,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
363
412
  };
364
413
  }
365
414
  catch (error) {
415
+ await finishCapture();
366
416
  const message = error instanceof Error ? error.message : "Failed to start execution.";
367
417
  const stats = executionMode?.stats || {};
368
418
  const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
@@ -468,7 +518,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
468
518
  throw error;
469
519
  }
470
520
  }
471
- 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, }) {
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, }) {
472
522
  await clearLoadmillReportMetadata(report?.metadataPath);
473
523
  const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
474
524
  const runConfiguration = buildDroidRunConfiguration({
@@ -546,6 +596,8 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
546
596
  runConfiguration,
547
597
  cloudUploadSession,
548
598
  uploadManager,
599
+ networkCapture,
600
+ initialBrowserUrl: index === 0 ? initialBrowserUrl : null,
549
601
  });
550
602
  aggregateStats.durationMs += result.durationMs ?? 0;
551
603
  aggregateStats.instructionsTotal += result.instructionsTotal ?? 0;
@@ -184,6 +184,9 @@ export function shouldWaitForLoadmillCloudCompletion(targetConfig) {
184
184
  const MOBILE_ONLY_CLOUD_SOURCES = new Set(["lambdatest", "aws-device-farm"]);
185
185
  export function resolveHeadlessTarget(args = {}) {
186
186
  const target = normalizeTarget(args.target);
187
+ if (target !== "desktop" && (args["desktop-app"] != null || args["desktop-window"] != null || args["desktop-input"] != null || args["list-desktop-apps"] != null)) {
188
+ throw new Error("--desktop-app, --desktop-window, --desktop-input, and --list-desktop-apps can only be used with --target desktop.");
189
+ }
187
190
  const targetGiven = hasNonEmptyString(args.target);
188
191
  const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
189
192
  const platform = normalizeMobilePlatform(args.platform, "--platform");
@@ -264,18 +267,25 @@ export function resolveHeadlessTarget(args = {}) {
264
267
  }
265
268
  const listDesktopApps = args["list-desktop-apps"] != null;
266
269
  const desktopApp = hasNonEmptyString(args["desktop-app"]) ? args["desktop-app"].trim() : null;
270
+ if (args["desktop-app"] != null && !desktopApp) {
271
+ throw new Error("--desktop-app requires an application ID or exact name.");
272
+ }
267
273
  const rawWindow = readNonEmptyCliValue(args["desktop-window"]);
268
274
  const desktopWindow = rawWindow == null ? null : Number(rawWindow);
269
275
  const desktopInput = hasNonEmptyString(args["desktop-input"]) ? args["desktop-input"].trim().toLowerCase() : "physical";
270
- if (!listDesktopApps && !desktopApp) {
271
- throw new Error("--desktop-app is required with --target desktop.");
276
+ if (args["desktop-window"] != null && !desktopApp) {
277
+ throw new Error("--desktop-window requires --desktop-app.");
272
278
  }
273
- if (rawWindow != null && (!Number.isInteger(desktopWindow) || desktopWindow < 0)) {
279
+ if (args["desktop-window"] != null && (rawWindow == null || !Number.isInteger(desktopWindow) || desktopWindow < 0)) {
274
280
  throw new Error("--desktop-window must be a non-negative integer window ID.");
275
281
  }
276
- if (desktopInput !== "accessibility" && desktopInput !== "physical") {
282
+ if ((args["desktop-input"] != null && !hasNonEmptyString(args["desktop-input"])) ||
283
+ (desktopInput !== "accessibility" && desktopInput !== "physical")) {
277
284
  throw new Error('--desktop-input must be "accessibility" or "physical".');
278
285
  }
286
+ if (desktopInput === "accessibility" && !desktopApp) {
287
+ throw new Error("--desktop-input accessibility requires --desktop-app. Full desktop uses physical input only.");
288
+ }
279
289
  return {
280
290
  target,
281
291
  sessionMode: null,
@@ -290,14 +300,12 @@ export function resolveHeadlessTarget(args = {}) {
290
300
  artifacts: null,
291
301
  listDevices: false,
292
302
  listDesktopApps,
303
+ desktopScope: desktopApp ? "window" : "desktop",
293
304
  desktopApp,
294
305
  desktopWindow,
295
306
  desktopInput,
296
307
  };
297
308
  }
298
- if (args["desktop-app"] != null || args["desktop-window"] != null || args["desktop-input"] != null || args["list-desktop-apps"] != null) {
299
- throw new Error("--desktop-app, --desktop-window, --desktop-input, and --list-desktop-apps can only be used with --target desktop.");
300
- }
301
309
  if (args["session-mode"] != null) {
302
310
  throw new Error("--session-mode can only be used with --target web.");
303
311
  }
@@ -11,19 +11,21 @@ import { CUA_MODELS } from '../core/cua-models.js';
11
11
  */
12
12
  export async function handleHelp(args, session, context) {
13
13
  const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
14
- addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile and web testing CLI' });
14
+ addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile, web, and desktop testing CLI' });
15
15
  addOutput({ type: 'info', text: '' });
16
16
  addOutput({ type: 'info', text: 'Usage:' });
17
17
  addOutput({ type: 'info', text: ' droid-cua --avd <device-name> [options]' });
18
18
  addOutput({ type: 'info', text: ' droid-cua run <file-or-directory...> [options]' });
19
19
  addOutput({ type: 'info', text: ' droid-cua --target web --instructions <file> [options]' });
20
+ addOutput({ type: 'info', text: ' droid-cua --target desktop --instructions <file> [options]' });
20
21
  addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --instructions <file> [options]' });
21
22
  addOutput({ type: 'info', text: '' });
22
23
  addOutput({ type: 'info', text: 'Options:' });
23
24
  addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default), web, or desktop' });
24
- addOutput({ type: 'info', text: ' --desktop-app <id> Running desktop app ID or exact name' });
25
- addOutput({ type: 'info', text: ' --desktop-window <id> Window ID when the app has multiple windows' });
26
- addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default) or cursor-free accessibility' });
25
+ addOutput({ type: 'info', text: ' Desktop controls the primary display by default' });
26
+ addOutput({ type: 'info', text: ' --desktop-app <id> Limit desktop control to a running app ID or exact name' });
27
+ addOutput({ type: 'info', text: ' --desktop-window <id> Select a window within --desktop-app' });
28
+ addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default); accessibility requires --desktop-app' });
27
29
  addOutput({ type: 'info', text: ' --list-desktop-apps List running desktop apps and windows' });
28
30
  addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
29
31
  addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
@@ -53,6 +55,8 @@ export async function handleHelp(args, session, context) {
53
55
  addOutput({ type: 'info', text: ' --record Record screenshots during execution' });
54
56
  addOutput({ type: 'info', text: ' --explain Explain non-obvious decisions and completion evidence' });
55
57
  addOutput({ type: 'info', text: ' --report [path] Save a self-contained HTML report locally' });
58
+ addOutput({ type: 'info', text: ' --network-proxy Forward local mobile traffic through 127.0.0.1:8765 without capture' });
59
+ addOutput({ type: 'info', text: ' --capture-network Capture local mobile or browser test traffic to one JSONL log per test' });
56
60
  addOutput({ type: 'info', text: ' --no-cloud-report Skip uploading the HTML report and screenshots to Loadmill' });
57
61
  addOutput({ type: 'info', text: ' --report-metadata <path> Write uploaded Loadmill report metadata as JSON' });
58
62
  addOutput({ type: 'info', text: ' --debug Enable structured JSONL debug artifacts' });
@@ -76,6 +80,7 @@ export async function handleHelp(args, session, context) {
76
80
  addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulator automation' });
77
81
  addOutput({ type: 'info', text: ' LambdaTest: Uses real cloud devices for headless mobile runs' });
78
82
  addOutput({ type: 'info', text: ' Web: Uses Playwright with installed Google Chrome or Microsoft Edge for browser execution' });
83
+ addOutput({ type: 'info', text: ' Desktop: Controls the primary display or a selected application on macOS and Windows' });
79
84
  addOutput({ type: 'info', text: '' });
80
85
  addOutput({ type: 'info', text: 'Platform Detection:' });
81
86
  addOutput({ type: 'info', text: ' - Use --platform flag to force a specific platform' });
@@ -98,6 +103,7 @@ export async function handleHelp(args, session, context) {
98
103
  addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
99
104
  addOutput({ type: 'info', text: ' droid-cua --instructions tests --test-data tests/test-data.md --scenario "Run every checkout test with each buyer account"' });
100
105
  addOutput({ type: 'info', text: ' droid-cua --target web --instructions tests/search.dcua' });
106
+ addOutput({ type: 'info', text: ' droid-cua --target desktop --instructions tests/launcher.dcua' });
101
107
  addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --platform android --device-name "Galaxy S24" --os-version 14 --app app.apk --instructions tests/login.dcua' });
102
108
  addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
103
109
  addOutput({ type: 'info', text: ' /list (see all tests)' });
@@ -1,3 +1,4 @@
1
+ import { answerNetworkQuestion, handleQueryNetworkToolCall, NETWORK_QUERY_FAILURE_MESSAGE } from "./tool-handlers/query-network.js";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { getScreenshotAsBase64, getCurrentPlatform } from "../device/connection.js";
3
4
  import { closeCurrentTab as closeCurrentBrowserTab, getBrowserInfo, navigateToUrl as navigateBrowserToUrl } from "../browser/connection.js";
@@ -246,6 +247,8 @@ export class ExecutionEngine {
246
247
  this.handleModelAction = options.handleModelAction || handleModelAction;
247
248
  this.closeKeyboard = options.closeKeyboard || closeKeyboard;
248
249
  this.sendCUARequest = options.sendCUARequest || sendCUARequest;
250
+ this.networkQuery = options.networkQuery || null;
251
+ this.answerNetworkQuestion = options.answerNetworkQuestion || answerNetworkQuestion;
249
252
  this.reviseTestScript = options.reviseTestScript || null;
250
253
  this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
251
254
  this.getBrowserInfo = options.getBrowserInfo || getBrowserInfo;
@@ -323,6 +326,8 @@ export class ExecutionEngine {
323
326
  switch (toolName) {
324
327
  case "preview_click":
325
328
  return await handlePreviewClick(handlerArgs);
329
+ case "query_network":
330
+ return await handleQueryNetworkToolCall(handlerArgs);
326
331
  case "assert":
327
332
  return await handleAssertionToolCall(handlerArgs);
328
333
  case "navigate":
@@ -408,7 +413,7 @@ export class ExecutionEngine {
408
413
  // The Desktop recorder returns one from disk; for runs without a recorder (e.g. a
409
414
  // plain CLI `--report`) render the same marker in-memory from the raw screenshot.
410
415
  const annotatedDataUrl = await this.resolveAnnotatedDataUrl(annotatedScreenshot, currentScreenshot, action, context);
411
- await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
416
+ const actionResult = await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
412
417
  ...context,
413
418
  shouldStop,
414
419
  stepId: stepContext?.stepId,
@@ -430,6 +435,13 @@ export class ExecutionEngine {
430
435
  ...(annotatedScreenshot?.screenshotPath ? { screenshotPath: annotatedScreenshot.screenshotPath } : {}),
431
436
  ...(context?.includeScreenshotDataUrl && annotatedDataUrl ? { screenshotDataUrl: annotatedDataUrl } : {})
432
437
  });
438
+ if (actionResult?.success === false) {
439
+ if (shouldStop())
440
+ return { stopped: true, sawExplicitScreenshotAction };
441
+ const actionError = `${action.type} failed: ${actionResult.error || "The action could not be completed."}`;
442
+ this.session.addToTranscript?.(`[Action Error] ${actionError}`);
443
+ return { refreshModelState: true, actionError, sawExplicitScreenshotAction };
444
+ }
433
445
  if (trackAction) {
434
446
  trackAction(action);
435
447
  }
@@ -599,6 +611,7 @@ export class ExecutionEngine {
599
611
  ...extra
600
612
  });
601
613
  let refreshBeforeNextComputerAction = false;
614
+ let consecutiveNetworkQueryFailures = 0;
602
615
  let observationOnlyTurnsSinceLastAction = 0;
603
616
  let observationOnlyRecoverySent = false;
604
617
  let pendingObservationOnlyRecoveryMessage = null;
@@ -745,6 +758,15 @@ export class ExecutionEngine {
745
758
  summary.error = toolResult.error;
746
759
  break;
747
760
  }
761
+ if (actionableItem.name === "query_network") {
762
+ consecutiveNetworkQueryFailures = toolResult.queryFailed ? consecutiveNetworkQueryFailures + 1 : 0;
763
+ if (consecutiveNetworkQueryFailures >= 3) {
764
+ summary.failed = true;
765
+ summary.error = NETWORK_QUERY_FAILURE_MESSAGE;
766
+ addOutput({ type: "error", text: summary.error, eventType: "error", ...eventMeta() });
767
+ break;
768
+ }
769
+ }
748
770
  if (toolResult.kind === "retry_instruction") {
749
771
  summary.retryInstruction = true;
750
772
  newResponseId = null;
@@ -823,6 +845,9 @@ export class ExecutionEngine {
823
845
  }
824
846
  if (actionBatchResult.refreshModelState) {
825
847
  const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, true, addOutput, eventMeta, context, stepContext);
848
+ if (actionBatchResult.actionError) {
849
+ followUp.input.push({ role: "user", content: `${actionBatchResult.actionError} The remaining actions were skipped. Use the fresh screenshot to decide what to do next.` });
850
+ }
826
851
  currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
827
852
  response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
828
853
  newResponseId = response.id;