@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.
- package/README.md +123 -8
- package/build/index.js +46 -17
- package/build/src/cli/device-selector.js +13 -6
- package/build/src/cli/headless-execution-config.js +9 -0
- package/build/src/cli/headless-report.js +40 -11
- package/build/src/cli/headless-runner.js +28 -14
- package/build/src/cli/headless-target.js +24 -0
- package/build/src/cli/ios-device-command.js +121 -0
- package/build/src/cli/ios-device-list.js +32 -0
- package/build/src/cli/network-capture-device.js +21 -0
- package/build/src/commands/help.js +15 -3
- package/build/src/commands/stop.js +1 -0
- package/build/src/core/capture-sources.js +4 -0
- package/build/src/core/droid-cua-run-usage.js +9 -2
- package/build/src/core/execution-context.js +8 -0
- package/build/src/core/execution-engine.js +3 -0
- package/build/src/core/report/render-html-report.js +299 -94
- package/build/src/core/report/run-log-model.js +84 -1
- package/build/src/core/report/run-report-model.js +4 -1
- package/build/src/core/report/run-report-sections.js +10 -0
- package/build/src/core/runtime-config.js +11 -1
- package/build/src/core/step-watchdog.js +61 -0
- package/build/src/desktop-shared/ios-real-device.js +82 -0
- package/build/src/device/factory.js +4 -0
- package/build/src/device/ios/actions.js +48 -12
- package/build/src/device/ios/appium-server.js +7 -18
- package/build/src/device/ios/connection.js +71 -12
- package/build/src/device/ios/node-cli-env.js +55 -0
- package/build/src/device/ios/real-device/device-state.js +40 -0
- package/build/src/device/ios/real-device/devicectl.js +154 -0
- package/build/src/device/ios/real-device/readiness.js +439 -0
- package/build/src/device/ios/real-device/session.js +176 -0
- package/build/src/device/ios/real-device/signing.js +466 -0
- package/build/src/device/ios/real-device/wda-provisioning.js +229 -0
- package/build/src/modes/execution-mode.js +157 -3
- package/build/src/network/certificate-authority.js +21 -1
- package/build/src/network/certificate-install.js +38 -0
- package/build/src/network/local-proxy.js +21 -1
- package/build/src/network/run-network-capture.js +16 -5
- package/build/src/prompts/step-watchdog.js +22 -0
- package/build/src/utils/desktop-debug.js +2 -2
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +2 -2
- package/skill/droid-cua/references/authoring-tests.md +1 -1
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +33 -2
- 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
|
|
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
|
|
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 **
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
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)` : ""} - ${
|
|
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 ${
|
|
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 :
|
|
19
|
+
ts: typeof item.ts === "string" && item.ts.trim() ? item.ts : new Date().toISOString(),
|
|
20
20
|
kind: typeof item.type === "string" ? item.type : "info",
|
|
21
21
|
text: typeof item.text === "string" ? item.text : "",
|
|
22
22
|
eventType: item.eventType,
|
|
@@ -45,26 +45,52 @@ export function createCapturingAddOutput(underlying, sink, { cloudSink = null, u
|
|
|
45
45
|
// boolean/error/event signals. Exactly one of {stopped, failures, errors} is ever set, so
|
|
46
46
|
// the shared precedence (error > fail > stopped > skipped > pass) yields the same verdict
|
|
47
47
|
// the CLI produced before — see R1.
|
|
48
|
-
function deriveCounts(success, error, events) {
|
|
48
|
+
function deriveCounts(success, error, events, failureKind) {
|
|
49
49
|
if (success)
|
|
50
50
|
return { failures: 0, errors: 0, stopped: false };
|
|
51
51
|
if (String(error || "").toLowerCase().includes("stopped by user")) {
|
|
52
52
|
return { failures: 0, errors: 0, stopped: true };
|
|
53
53
|
}
|
|
54
|
+
if (failureKind === "step_watchdog_error")
|
|
55
|
+
return { failures: 0, errors: 1, stopped: false };
|
|
56
|
+
if (failureKind === "step_watchdog")
|
|
57
|
+
return { failures: 1, errors: 0, stopped: false };
|
|
54
58
|
const failedAssertion = events.some((event) => event.eventType === "assertion_result" && event.payload?.passed === false) ||
|
|
55
59
|
String(error || "").startsWith("Assertion failed:");
|
|
56
60
|
return failedAssertion ? { failures: 1, errors: 0, stopped: false } : { failures: 0, errors: 1, stopped: false };
|
|
57
61
|
}
|
|
62
|
+
function deviceReportEvents(deviceInfo, startedAtIso, testName) {
|
|
63
|
+
if (!deviceInfo)
|
|
64
|
+
return [];
|
|
65
|
+
return [{
|
|
66
|
+
ts: startedAtIso ?? null,
|
|
67
|
+
kind: "system",
|
|
68
|
+
text: `Loading test: ${testName ?? "Test run"}`,
|
|
69
|
+
eventType: "run_started",
|
|
70
|
+
payload: {
|
|
71
|
+
deviceName: deviceInfo.device_name,
|
|
72
|
+
platform: deviceInfo.platform,
|
|
73
|
+
deviceSource: deviceInfo.source,
|
|
74
|
+
osVersion: deviceInfo.os_version,
|
|
75
|
+
},
|
|
76
|
+
}];
|
|
77
|
+
}
|
|
58
78
|
// Builds a RunReportModel from captured CLI events plus the run's final stats. This is the
|
|
59
79
|
// CLI-acquisition adapter: it knows how to capture live events + run stats, then delegates
|
|
60
80
|
// ALL model assembly to the shared run-report-model.js (same code path as the XML/Desktop
|
|
61
81
|
// parser). A `run_finished` event is synthesized when the captured output stream omits one.
|
|
62
|
-
export function buildCliRunReportModel({ suiteName, testName, startedAtIso, durationMs, success, error, instructionsTotal, stats = {}, events, }) {
|
|
82
|
+
export function buildCliRunReportModel({ suiteName, testName, startedAtIso, durationMs, success, error, failureKind = undefined, stepWatchdogMode = undefined, stepWatchdogUsage = undefined, instructionsTotal, stats = {}, events, deviceInfo = undefined, }) {
|
|
63
83
|
const warnings = [];
|
|
64
84
|
const normalizedEvents = normalizeEvents(events, { fallbackTs: startedAtIso ?? null, warnings });
|
|
85
|
+
const terminalCheck = normalizedEvents.findLast((event) => event.eventType === "step_watchdog_result" && event.payload?.mode === "on");
|
|
86
|
+
failureKind ??= terminalCheck?.payload?.outcome === "failure" ? "step_watchdog"
|
|
87
|
+
: terminalCheck?.payload?.outcome === "error" ? "step_watchdog_error" : undefined;
|
|
65
88
|
const summaryPayload = buildRunFinishedPayload({
|
|
66
89
|
success,
|
|
67
90
|
error,
|
|
91
|
+
failureKind,
|
|
92
|
+
stepWatchdogMode,
|
|
93
|
+
stepWatchdogUsage,
|
|
68
94
|
durationMs,
|
|
69
95
|
instructionsTotal,
|
|
70
96
|
instructionsCompleted: stats.instructionsCompleted,
|
|
@@ -74,15 +100,18 @@ export function buildCliRunReportModel({ suiteName, testName, startedAtIso, dura
|
|
|
74
100
|
retries: stats.retryCount,
|
|
75
101
|
});
|
|
76
102
|
const hasRunFinished = normalizedEvents.some((event) => event.eventType === "run_finished");
|
|
77
|
-
//
|
|
78
|
-
|
|
103
|
+
// Use execution time, not report generation/upload time, for the final step boundary.
|
|
104
|
+
const startedAtMs = Date.parse(startedAtIso);
|
|
105
|
+
const finishedAt = Number.isFinite(startedAtMs) && Number.isFinite(durationMs) && durationMs >= 0
|
|
106
|
+
? new Date(startedAtMs + durationMs).toISOString()
|
|
107
|
+
: null;
|
|
79
108
|
const allEvents = hasRunFinished
|
|
80
109
|
? normalizedEvents
|
|
81
110
|
: [
|
|
82
111
|
...normalizedEvents,
|
|
83
|
-
{ ts:
|
|
112
|
+
{ ts: finishedAt, kind: "system", text: "Execution finished", eventType: "run_finished", payload: summaryPayload },
|
|
84
113
|
];
|
|
85
|
-
const { failures, errors, stopped } = deriveCounts(success, error, allEvents);
|
|
114
|
+
const { failures, errors, stopped } = deriveCounts(success, error, allEvents, failureKind);
|
|
86
115
|
const status = deriveRunStatus({ tests: 1, failures, errors, stopped });
|
|
87
116
|
const successRate = computeSuccessRate({ tests: 1, failures, errors, status });
|
|
88
117
|
return assembleRunReportModel({
|
|
@@ -96,7 +125,7 @@ export function buildCliRunReportModel({ suiteName, testName, startedAtIso, dura
|
|
|
96
125
|
failureOrError: success
|
|
97
126
|
? undefined
|
|
98
127
|
: makeFailureOrError({ kind: status === "error" ? "error" : "failure", message: error ?? undefined }),
|
|
99
|
-
events: allEvents,
|
|
128
|
+
events: [...deviceReportEvents(deviceInfo, startedAtIso, testName), ...allEvents],
|
|
100
129
|
warnings,
|
|
101
130
|
});
|
|
102
131
|
}
|
|
@@ -127,7 +156,7 @@ export function createCliRunReport(modelInput) {
|
|
|
127
156
|
}),
|
|
128
157
|
};
|
|
129
158
|
}
|
|
130
|
-
export function createCliAggregateRunReport({ suiteName, testName, startedAtIso, durationMs, childModels, }) {
|
|
159
|
+
export function createCliAggregateRunReport({ suiteName, testName, startedAtIso, durationMs, childModels, deviceInfo = undefined, }) {
|
|
131
160
|
const childTests = childModels.map((model) => ({
|
|
132
161
|
testName: model.testName ?? "Test",
|
|
133
162
|
durationSeconds: model.durationSeconds,
|
|
@@ -162,7 +191,7 @@ export function createCliAggregateRunReport({ suiteName, testName, startedAtIso,
|
|
|
162
191
|
skipped,
|
|
163
192
|
status,
|
|
164
193
|
}),
|
|
165
|
-
events:
|
|
194
|
+
events: deviceReportEvents(deviceInfo, startedAtIso, testName),
|
|
166
195
|
childTests,
|
|
167
196
|
warnings: [],
|
|
168
197
|
});
|
|
@@ -183,7 +212,7 @@ export function renderCliCloudReport(model, savedRunId) {
|
|
|
183
212
|
for (const child of cloudModel.childTests ?? []) {
|
|
184
213
|
replaceScreenshots(child.events);
|
|
185
214
|
}
|
|
186
|
-
return renderHtmlReport(cloudModel, { includeScripts: false });
|
|
215
|
+
return renderHtmlReport(cloudModel, { includeScripts: false, showRunDetails: false });
|
|
187
216
|
}
|
|
188
217
|
export async function writeCliRunReportArtifact(artifact, { reportPath, cwd = process.cwd(), isBatch = false } = {}) {
|
|
189
218
|
const outputPath = resolveReportOutputPath({
|