@loadmill/droid-cua 2.43.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.
Files changed (46) hide show
  1. package/README.md +123 -8
  2. package/build/index.js +46 -17
  3. package/build/src/cli/device-selector.js +13 -6
  4. package/build/src/cli/headless-execution-config.js +9 -0
  5. package/build/src/cli/headless-report.js +40 -11
  6. package/build/src/cli/headless-runner.js +28 -14
  7. package/build/src/cli/headless-target.js +24 -0
  8. package/build/src/cli/ios-device-command.js +121 -0
  9. package/build/src/cli/ios-device-list.js +32 -0
  10. package/build/src/cli/network-capture-device.js +21 -0
  11. package/build/src/commands/help.js +15 -3
  12. package/build/src/commands/stop.js +1 -0
  13. package/build/src/core/capture-sources.js +4 -0
  14. package/build/src/core/droid-cua-run-usage.js +9 -2
  15. package/build/src/core/execution-context.js +8 -0
  16. package/build/src/core/execution-engine.js +3 -0
  17. package/build/src/core/report/render-html-report.js +299 -94
  18. package/build/src/core/report/run-log-model.js +84 -1
  19. package/build/src/core/report/run-report-model.js +4 -1
  20. package/build/src/core/report/run-report-sections.js +10 -0
  21. package/build/src/core/runtime-config.js +11 -1
  22. package/build/src/core/step-watchdog.js +61 -0
  23. package/build/src/desktop-shared/ios-real-device.js +82 -0
  24. package/build/src/device/factory.js +4 -0
  25. package/build/src/device/ios/actions.js +48 -12
  26. package/build/src/device/ios/appium-server.js +7 -18
  27. package/build/src/device/ios/connection.js +71 -12
  28. package/build/src/device/ios/node-cli-env.js +55 -0
  29. package/build/src/device/ios/real-device/device-state.js +40 -0
  30. package/build/src/device/ios/real-device/devicectl.js +154 -0
  31. package/build/src/device/ios/real-device/readiness.js +439 -0
  32. package/build/src/device/ios/real-device/session.js +176 -0
  33. package/build/src/device/ios/real-device/signing.js +466 -0
  34. package/build/src/device/ios/real-device/wda-provisioning.js +229 -0
  35. package/build/src/modes/execution-mode.js +157 -3
  36. package/build/src/network/certificate-authority.js +21 -1
  37. package/build/src/network/certificate-install.js +38 -0
  38. package/build/src/network/local-proxy.js +21 -1
  39. package/build/src/network/run-network-capture.js +16 -5
  40. package/build/src/prompts/step-watchdog.js +22 -0
  41. package/build/src/utils/desktop-debug.js +2 -2
  42. package/package.json +1 -1
  43. package/skill/droid-cua/SKILL.md +2 -2
  44. package/skill/droid-cua/references/authoring-tests.md +1 -1
  45. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +33 -2
  46. package/skill/droid-cua/references/running-and-healing.md +27 -2
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
  ---
@@ -69,10 +75,10 @@ You can also keep project run history in a results folder and review past runs f
69
75
  <h2 id="platforms">📱 Platforms</h2>
70
76
 
71
77
  - **Android** - Physical devices and emulators
72
- - **iOS** - Simulators on macOS
78
+ - **iOS** - Simulators on macOS, and iPhones connected to your Mac by USB
73
79
 
74
80
  Desktop app support:
75
- - macOS: Android and iOS simulator workflows
81
+ - macOS: Android, iOS simulator, and iPhone (USB) workflows
76
82
  - Windows: Android workflows only
77
83
 
78
84
  ## Requirements
@@ -90,6 +96,8 @@ Desktop app support:
90
96
  - Appium
91
97
  - XCUITest driver
92
98
 
99
+ **iPhone over USB (macOS only):** see [Testing on a real iPhone](#testing-on-a-real-iphone).
100
+
93
101
  ---
94
102
 
95
103
  <h2 id="features">✨ Features</h2>
@@ -97,7 +105,7 @@ Desktop app support:
97
105
  - **Desktop-first workflow** - Create, run, and manage tests from one app
98
106
  - **Setup guidance** - Configure API access and platform prerequisites in the app
99
107
  - **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
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
101
109
  - **Natural-language test creation** - Describe flows in plain English
102
110
  - **Test management** - Create, edit, save, and rerun reusable scripts, including tests organized in nested project folders
103
111
  - **Live execution logs** - Watch actions and progress as tests run
@@ -106,15 +114,19 @@ Desktop app support:
106
114
  - **Run video export** - Generate and download a polished MP4 replay from a desktop run's screenshots and instruction timeline
107
115
  - **Headless support** - Reuse scripts in CLI and automation workflows
108
116
 
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.
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.
110
118
 
111
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.
112
120
 
113
121
  **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
122
 
123
+ **Physical iPhone capture:** A USB-connected iPhone reaches the proxy over Wi-Fi, so Droid listens on all Mac network interfaces (`0.0.0.0:8765`) instead of `127.0.0.1` while an iPhone is the target: in the CLI when `--avd ios-device:<udid|auto>` is used with `--capture-network` or `--network-proxy`, and in the desktop app while Network Capture is on and a local iPhone is connected (the proxy switches back to `127.0.0.1` on disconnect, but not during a run). Droid prints the setting to use, for example `On the iPhone, set Wi-Fi → Configure Proxy → Manual: server 192.168.1.23, port 8765.` Put the Mac and iPhone on the same Wi-Fi network with device-to-device traffic allowed, install and fully trust the Droid CA manually as described above, and set the iPhone proxy by hand; Droid never changes iPhone settings. Turn the iPhone proxy off after testing. Anyone on the same network can use the proxy while it listens on `0.0.0.0`, so use a trusted network. Apps that pin certificates still connect through the proxy, but their HTTPS is not decrypted.
124
+
125
+ **Physical Android phone capture:** A phone connected over ADB (any serial other than `emulator-<port>`) is handled the same way as a physical iPhone: Droid listens on `0.0.0.0:8765`, in the CLI once the connected device turns out to be a phone (with `--capture-network` or `--network-proxy`) and in the desktop app while Network Capture is on and the phone is connected. Droid prints the setting to use, for example `On the Android phone, set Wi-Fi → Edit network → Proxy → Manual: hostname 192.168.1.23, port 8765.` Install the CA as a user certificate as described above and set the phone's Wi-Fi proxy by hand; Droid does not root the phone or change its settings, and turn the proxy off after testing. Since Android 7, apps trust user-installed CAs only when their network security config allows it, for example `<certificates src="user"/>` in `network_security_config.xml` or a `<debug-overrides>` block in a debuggable build. Debug and QA builds usually opt in; Play Store builds usually do not, so their HTTPS is not decrypted. Chrome trusts user CAs. Rooted-device system trust is not supported.
126
+
115
127
  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
128
 
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.
129
+ 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. Ordinary iOS runs without capture do not require proxy or certificate preparation.
118
130
 
119
131
  **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
132
 
@@ -207,6 +219,10 @@ droid-cua run tests/auth/login.dcua tests/checkout/purchase.dcua admin/refund.dc
207
219
  # Headless iOS simulator run
208
220
  droid-cua run tests/login.dcua --platform ios --avd "iPhone 16"
209
221
 
222
+ # Check an iPhone connected by USB, then run on it
223
+ droid-cua ios-device check --fix
224
+ droid-cua run tests/login.dcua --avd ios-device:auto
225
+
210
226
  # List running desktop applications and their window IDs (macOS or Windows)
211
227
  droid-cua --target desktop --list-desktop-apps
212
228
 
@@ -287,7 +303,8 @@ droid-cua \
287
303
  ```
288
304
 
289
305
  Supported CLI options include:
290
- - `--avd`
306
+ - `--avd` (including `ios-device:<udid>` or `ios-device:auto` for an iPhone connected by USB)
307
+ - `--ios-team`
291
308
  - `--platform`
292
309
  - `--device-source`
293
310
  - `--device-name`
@@ -308,13 +325,14 @@ Supported CLI options include:
308
325
  - `--context-optimization-threshold`
309
326
  - `--no-context-optimization`
310
327
  - `--explain`
328
+ - `--step-watchdog <off|on>`
311
329
  - `--base-prompt`
312
330
  - `--execution-prompt`
313
331
  - `--base-prompt-file`
314
332
  - `--execution-prompt-file`
315
333
  - `--secrets`
316
334
  - `--record`
317
- - `--network-proxy` (local mobile sessions only; starts idle forwarding at `127.0.0.1:8765`)
335
+ - `--network-proxy` (local mobile sessions only; starts idle forwarding at `127.0.0.1:8765`, or `0.0.0.0:8765` for `--avd ios-device:<udid|auto>` and physical Android phones)
318
336
  - `--capture-network` (local mobile and Chrome/Edge test runs; writes a JSONL log for each test, using a proxy only for mobile)
319
337
  - `--report`
320
338
  - `--report-metadata <path>`
@@ -348,6 +366,7 @@ Config and precedence rules:
348
366
  - `--no-context-optimization` disables Context Optimization.
349
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.
350
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.
351
370
  - `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
352
371
  - `--base-prompt-file` and `--execution-prompt-file` override the corresponding prompt customizations from config.
353
372
  - If both inline and file-based prompt overrides are provided, the inline prompt flags win.
@@ -375,6 +394,102 @@ HTML report (`--report`):
375
394
  droid-cua run tests/login.dcua --avd adb:emulator-5554 --report ./reports
376
395
  ```
377
396
 
397
+ <h3 id="testing-on-a-real-iphone">Testing on a real iPhone</h3>
398
+
399
+ The desktop app and the CLI can run tests on your own iPhone connected to your Mac by USB, on the
400
+ apps already installed on it (TestFlight, App Store, or your team's builds).
401
+ droid-cua installs a small helper app (WebDriverAgent) on the iPhone, signed
402
+ with your company's Apple team; nothing is built from source at run time.
403
+
404
+ Requirements:
405
+ - macOS with an Xcode that supports the iPhone's iOS version, opened once.
406
+ Its first launch installs the device components and asks for a Mac admin password.
407
+ - The iOS platform installed in that Xcode (**Xcode > Settings > Components**, or
408
+ `xcodebuild -downloadPlatform iOS`; about 10 GB). Xcode 26 doesn't include it.
409
+ - Your Apple ID signed into Xcode (**Xcode > Settings > Apple Accounts**), as a
410
+ member of your company's **paid** Apple Developer team, with at least the
411
+ **Developer** role and **Access to Certificates, Identifiers & Profiles**.
412
+ Free Apple IDs are not supported.
413
+ - The Appium XCUITest driver, as for Simulators.
414
+ - On the iPhone, once: connect it by USB, unlock it and tap **Trust**; turn on
415
+ **Developer Mode** (**Settings > Privacy & Security > Developer Mode**, then let it
416
+ restart and confirm); set **Auto-Lock** to **Never** (**Settings > Display & Brightness**).
417
+ - A test-only iPhone is recommended: droid-cua tests the apps and data already on
418
+ the phone and never resets it.
419
+
420
+ Check the iPhone and set it up:
421
+
422
+ ```sh
423
+ droid-cua ios-device check # report each check and how to fix it
424
+ droid-cua ios-device check --fix # also set up signing and install the helper app
425
+ ```
426
+
427
+ `ios-device check` prints every check as passed (`✔`), failed (`✖`), or a
428
+ reminder (`!`), with the exact fix for each failure, and exits `0` only when the
429
+ iPhone is ready. Use `--udid <udid>` to choose an iPhone (the first one connected
430
+ by USB is used otherwise). The first `--fix` on a Mac runs a one-time Xcode signing
431
+ build (about a minute); installing the helper app takes a few seconds. A test run
432
+ performs the same checks and fixes automatically before it starts.
433
+
434
+ **Xcode installed** names what's wrong: Xcode isn't in `/Applications`, the
435
+ Command Line Tools are selected instead of Xcode (fix: `sudo xcode-select -s
436
+ /Applications/Xcode.app`), or the Xcode license isn't accepted (fix: `sudo
437
+ xcodebuild -license accept`). **iOS platform installed** checks that Xcode has the
438
+ iOS platform for its iOS SDK, without which Xcode can't build for an iPhone (fix:
439
+ install it from **Xcode > Settings > Components**, or run `xcodebuild
440
+ -downloadPlatform iOS`, about 10 GB). **Apple team access** reads your teams from Xcode;
441
+ on a fresh Mac, where Xcode hasn't saved them yet, droid-cua fetches them from
442
+ Apple with a short Xcode build (a few seconds, at most once a minute). It then
443
+ says whether no Apple account is signed into Xcode, or you're signed in but none
444
+ of your accounts is in a paid Apple Developer team.
445
+
446
+ Run tests on the iPhone:
447
+
448
+ ```sh
449
+ droid-cua run tests/login.dcua --avd ios-device:auto
450
+ droid-cua run tests/login.dcua --avd ios-device:00008120-001C18501A81A01E --ios-team ABCDE12345
451
+ ```
452
+
453
+ - `--avd ios-device:<udid>` selects an iPhone; `ios-device:auto` picks the first
454
+ iPhone connected by USB. The platform is detected as iOS from the token.
455
+ Running `droid-cua` without `--avd` lists connected iPhones next to Simulators.
456
+ - `--ios-team <TEAMID>` chooses the Apple team that signs the helper app when you
457
+ belong to several paid teams. `DROID_CUA_IOS_TEAM_ID` works too; the flag wins.
458
+ - `--app` is not used for a local iPhone: tests run against apps already installed
459
+ on it, so open the app in your test instructions (for example, `Open the Settings app`).
460
+ - After the iPhone restarts, unlock it. On the first run after a restart or a new
461
+ helper install, the iPhone may ask for your passcode to allow automation; enter
462
+ it and droid-cua continues. If the iPhone locks during a run, droid-cua pauses
463
+ until you unlock it.
464
+ - After a major iOS update, droid-cua tells you which Xcode to install and open once.
465
+
466
+ In the desktop app:
467
+
468
+ - Run the Setup Wizard (**Help > Run Setup Wizard**) and choose **iPhone (USB)**.
469
+ The **Connect iPhone** checklist runs the same checks as `ios-device check`.
470
+ Steps you do on the iPhone or in Xcode (plug in, Trust, Developer Mode, unlock,
471
+ device components) re-check every few seconds and turn green on their own.
472
+ **Set up signing** and **Install helper app** run the fix and stream its progress.
473
+ When the Command Line Tools are selected or the Xcode license isn't accepted,
474
+ **Xcode installed** offers **Set Xcode Developer Path** or **Accept Xcode License**
475
+ (both ask for a Mac admin password). When the iOS platform is missing,
476
+ **iOS platform installed** offers **Download iOS Platform**, which runs
477
+ `xcodebuild -downloadPlatform iOS` (about 10 GB).
478
+ Auto-Lock and the dedicated test iPhone are shown as reminders and don't block.
479
+ - If you belong to several paid Apple teams, choose the team in the wizard. The
480
+ desktop app remembers it and uses it for every iPhone run.
481
+ - Connected iPhones appear in the iOS device picker next to Simulators, with a
482
+ hint when the iPhone isn't trusted yet or Developer Mode is off. droid tests the
483
+ apps already installed on the iPhone and never installs any.
484
+ - The connection log and the run view highlight what needs you on the iPhone:
485
+ *Enter your passcode on the iPhone to allow automation*, *iPhone locked — unlock
486
+ to continue*, and *Your iPhone is on iOS N. Install Xcode N and open it once.*
487
+
488
+ Known limits: USB only (no Wi-Fi), one iPhone at a time, attended runs (someone
489
+ needs to unlock the phone and approve prompts), macOS only, and no `.ipa`
490
+ installation. Verified on iOS 26; iOS 17 to 26 are expected to work. iOS betas
491
+ and iOS 27 are not supported yet.
492
+
378
493
  LambdaTest CLI runs:
379
494
  - Use `--device-source lambdatest` with `--platform`, `--device-name`, `--os-version`, `--app`, and `--instructions`.
380
495
  - Set `LAMBDATEST_USERNAME` and `LAMBDATEST_ACCESS_KEY` in the environment.
@@ -453,7 +568,7 @@ droid-cua \
453
568
 
454
569
  Headless debug artifacts:
455
570
  - `--debug` writes desktop-style structured JSONL artifacts under `logs/`.
456
- - 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.
457
572
  - Each run also creates a sibling screenshot folder next to that JSONL file.
458
573
  - Shared device events are written to `logs/device-events.jsonl`.
459
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";
@@ -19,7 +21,7 @@ import { resolveExecutionTestData } from "./src/core/test-data.js";
19
21
  import { getDefaultSecretsPath, resolveExecutionSecrets } from "./src/core/secrets.js";
20
22
  import { assertRuntimeCredentialsForProvider, createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
21
23
  import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
22
- import { artifactKindsForPool, resolveHeadlessTarget, scopeArtifactKindsToPool, shouldWaitForLoadmillCloudCompletion, } from "./src/cli/headless-target.js";
24
+ import { artifactKindsForPool, resolveHeadlessTarget, resolveIosTeamId, scopeArtifactKindsToPool, shouldWaitForLoadmillCloudCompletion, } from "./src/cli/headless-target.js";
23
25
  import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
24
26
  import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
25
27
  import { clearLoadmillReportMetadata } from "./src/cli/loadmill-report-metadata.js";
@@ -38,6 +40,7 @@ import { droidReportUploadManager } from "./src/integrations/loadmill/report-upl
38
40
  import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
39
41
  import { setCloudProgressListener } from "./src/device/cloud/progress.js";
40
42
  import { runSkillCommand } from "./src/cli/skill-command.js";
43
+ import { runIosDeviceCommand } from "./src/cli/ios-device-command.js";
41
44
  import { formatLoadmillCloudNotEntitledMessage, isLoadmillCloudNotEntitledError, isLoadmillCloudUsageLimitError, } from "./src/device/cloud/loadmill-cloud/errors.js";
42
45
  import { fetchAndDownloadArtifacts, loadmillCloudAdapter } from "./src/device/cloud/loadmill-cloud/adapter.js";
43
46
  import { resolveRunPool } from "./src/device/cloud/loadmill-cloud/run-pool.js";
@@ -46,10 +49,26 @@ import { formatDuration } from "./src/core/report/run-log-model.js";
46
49
  import { createBrowserSessionTeardown, withLoadmillCloudSession } from "./src/cli/loadmill-cloud-session.js";
47
50
  import { createDesktopController, listDesktopApplications } from "./src/desktop/computer-use.js";
48
51
  import { createRunNetworkCapture } from "./src/network/run-network-capture.js";
52
+ import { cliNetworkCaptureDeviceKind, cliNetworkProxyTarget } from "./src/cli/network-capture-device.js";
49
53
  import { loadCertificateAuthority } from "./src/network/certificate-authority.js";
50
54
  import { ensureCliAndroidCertificate, inspectNetworkDevice } from "./src/network/certificate-install.js";
51
55
  dotenv.config();
52
56
  let networkCapture = null;
57
+ let cliNetworkProxyHost = null;
58
+ async function startCliNetworkProxy(deviceName) {
59
+ const { host, instruction } = cliNetworkProxyTarget(deviceName);
60
+ if (host === cliNetworkProxyHost)
61
+ return;
62
+ try {
63
+ await networkCapture.startProxy({ host });
64
+ }
65
+ catch (error) {
66
+ throw new Error(`Droid network proxy could not start: ${error.message}`, { cause: error });
67
+ }
68
+ cliNetworkProxyHost = host;
69
+ if (instruction)
70
+ console.log(instruction);
71
+ }
53
72
  process.once("SIGTERM", async () => {
54
73
  process.exitCode = 143;
55
74
  await networkCapture?.shutdown().catch(() => { });
@@ -78,12 +97,23 @@ if (args._[0] === "skill") {
78
97
  process.exit(1);
79
98
  }
80
99
  }
100
+ if (args._[0] === "ios-device") {
101
+ try {
102
+ process.exit(await runIosDeviceCommand({ args }));
103
+ }
104
+ catch (error) {
105
+ const message = error instanceof Error ? error.message : String(error);
106
+ console.error(`\niOS device command failed: ${message}`);
107
+ process.exit(1);
108
+ }
109
+ }
81
110
  const reportMetadataPath = typeof args["report-metadata"] === "string"
82
111
  && args["report-metadata"].trim().length > 0
83
112
  ? args["report-metadata"].trim()
84
113
  : null;
85
114
  await clearLoadmillReportMetadata(reportMetadataPath);
86
115
  const targetConfig = resolveHeadlessTarget(args);
116
+ const iosTeamId = resolveIosTeamId(args, targetConfig);
87
117
  let avdName = targetConfig.avdName;
88
118
  let platform = targetConfig.platform || null; // 'ios' or 'android'
89
119
  const recordScreenshots = args["record"] || false;
@@ -413,14 +443,8 @@ async function main() {
413
443
  process.exit(130);
414
444
  });
415
445
  }
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
- }
446
+ if (networkCapture && !isBrowserTarget)
447
+ await startCliNetworkProxy(avdName);
424
448
  let preloadedHeadlessConfig = null;
425
449
  let preloadedHeadlessTarget = null;
426
450
  let headlessInstructionRuns = [];
@@ -463,6 +487,8 @@ async function main() {
463
487
  const selection = await selectDevice();
464
488
  platform = selection.platform;
465
489
  avdName = selection.deviceName;
490
+ if (networkCapture)
491
+ await startCliNetworkProxy(avdName);
466
492
  }
467
493
  let targetId;
468
494
  let deviceInfo;
@@ -801,18 +827,18 @@ async function main() {
801
827
  }
802
828
  }
803
829
  else {
804
- targetId = await connectToDevice(avdName, platform);
830
+ targetId = await connectToDevice(avdName, platform, { teamId: iosTeamId });
831
+ const deviceKind = networkCapture ? cliNetworkCaptureDeviceKind(getConnectedDevicePlatform(), targetId) : null;
832
+ if (deviceKind === "android-device")
833
+ await startCliNetworkProxy(`adb:${targetId}`);
805
834
  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.");
835
+ if (deviceKind === "android-emulator") {
810
836
  const ca = await loadCertificateAuthority();
811
837
  const trust = await ensureCliAndroidCertificate(targetId, ca);
812
838
  if (trust.changed)
813
839
  console.log(`Reapplied Droid's network certificate on ${targetId}.`);
814
840
  }
815
- else {
841
+ else if (deviceKind === "ios-simulator") {
816
842
  await inspectNetworkDevice({ platform: "ios", deviceId: targetId });
817
843
  }
818
844
  }
@@ -826,6 +852,7 @@ async function main() {
826
852
  session.setSystemPrompt(initialSystemText);
827
853
  // If tests were selected, run in headless mode.
828
854
  if (isHeadlessInstructionsRun) {
855
+ const runId = randomUUID();
829
856
  let exitCode = null;
830
857
  let succeeded = false;
831
858
  try {
@@ -836,6 +863,7 @@ async function main() {
836
863
  llmProviderMode: headlessConfig.llmProviderMode,
837
864
  cuaModel: headlessConfig.cuaModel,
838
865
  explainMode: headlessConfig.explainMode,
866
+ stepWatchdogMode: headlessConfig.stepWatchdogMode,
839
867
  promptCustomizations: headlessConfig.promptCustomizations,
840
868
  contextOptimizationEnabled: headlessConfig.contextOptimizationEnabled,
841
869
  contextOptimizationThreshold: headlessConfig.contextOptimizationThreshold,
@@ -851,7 +879,7 @@ async function main() {
851
879
  console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
852
880
  if (headlessConfig.scenarioPrompt) {
853
881
  const candidateRuns = await loadScenarioCandidateRuns(headlessInstructionRuns);
854
- const scenarioCases = await planScenarioRuns({
882
+ const scenarioCases = await withExecutionRunId(runId, () => planScenarioRuns({
855
883
  client: runtimeContext.openAiClient,
856
884
  runtimeConfig: runtimeContext.config,
857
885
  scenarioPrompt: headlessConfig.scenarioPrompt,
@@ -861,7 +889,7 @@ async function main() {
861
889
  })),
862
890
  appContextText,
863
891
  testDataText: preloadedTestData?.text || "",
864
- });
892
+ }));
865
893
  printScenarioCasePreview(scenarioCases);
866
894
  headlessInstructionRuns = buildScenarioRuns({
867
895
  candidateRuns,
@@ -870,6 +898,7 @@ async function main() {
870
898
  preloadedHeadlessTarget = buildScenarioInstructionTarget(preloadedHeadlessTarget, headlessConfig.scenarioPrompt);
871
899
  }
872
900
  const result = await runHeadlessInstructionRuns({
901
+ runId,
873
902
  runs: headlessInstructionRuns,
874
903
  instructionsTarget: preloadedHeadlessTarget,
875
904
  session,
@@ -5,6 +5,7 @@ import React, { useState } from 'react';
5
5
  import { render, Box, Text, useInput, useApp } from 'ink';
6
6
  import { exec } from "child_process";
7
7
  import { promisify } from "util";
8
+ import { getConnectedIphones } from "./ios-device-list.js";
8
9
  const execAsync = promisify(exec);
9
10
  /**
10
11
  * Interactive selection component with arrow key navigation
@@ -171,16 +172,18 @@ async function getIOSDevices() {
171
172
  */
172
173
  export async function selectDevice() {
173
174
  // Check what's available
174
- const [androidDevices, iosDevices] = await Promise.all([
175
+ const [androidDevices, iphones, simulators] = await Promise.all([
175
176
  getAndroidDevices(),
177
+ getConnectedIphones(),
176
178
  getIOSDevices(),
177
179
  ]);
180
+ const iosDevices = [...iphones, ...simulators];
178
181
  const hasAndroid = androidDevices.length > 0;
179
182
  const hasIOS = iosDevices.length > 0;
180
183
  if (!hasAndroid && !hasIOS) {
181
184
  console.error("\nNo devices found!");
182
185
  console.error(" Android: Connect a device or create an AVD, then run droid-cua again");
183
- console.error(" iOS: Xcode Simulator must be available");
186
+ console.error(" iOS: Connect an iPhone by USB or make an Xcode Simulator available");
184
187
  process.exit(1);
185
188
  }
186
189
  // Build platform options
@@ -193,9 +196,13 @@ export async function selectDevice() {
193
196
  });
194
197
  }
195
198
  if (hasIOS) {
196
- const runningCount = iosDevices.filter((d) => d.running).length;
199
+ const runningCount = simulators.filter((d) => d.running).length;
200
+ const counts = [
201
+ iphones.length > 0 ? `${iphones.length} iPhone(s)` : null,
202
+ simulators.length > 0 ? `${simulators.length} simulator(s)` : null,
203
+ ].filter(Boolean).join(", ");
197
204
  platformOptions.push({
198
- label: `iOS${runningCount > 0 ? ` (${runningCount} running)` : ""} - ${iosDevices.length} simulator(s)`,
205
+ label: `iOS${runningCount > 0 ? ` (${runningCount} running)` : ""} - ${counts}`,
199
206
  value: "ios",
200
207
  });
201
208
  }
@@ -211,11 +218,11 @@ export async function selectDevice() {
211
218
  }
212
219
  // Select device
213
220
  const deviceList = platform === "ios" ? iosDevices : androidDevices;
214
- const deviceType = platform === "ios" ? "Simulator" : "Android Target";
221
+ const deviceType = platform === "ios" ? (iphones.length > 0 ? "iOS Device" : "Simulator") : "Android Target";
215
222
  let deviceName;
216
223
  if (deviceList.length === 1) {
217
224
  deviceName = deviceList[0].value;
218
- console.log(`Using ${deviceName} (only available ${deviceType.toLowerCase()})\n`);
225
+ console.log(`Using ${deviceList[0].label} (only available ${deviceType.toLowerCase()})\n`);
219
226
  }
220
227
  else {
221
228
  const selected = await renderSelection(`Select ${deviceType}`, deviceList);
@@ -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 : null,
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
- // The synthetic run_finished is already in canonical shape; append it as-is (ts stays
78
- // null, matching the historical CLI output) only when the producer did not emit one.
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: null, kind: "system", text: "Execution finished", eventType: "run_finished", payload: summaryPayload },
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({