@loadmill/droid-cua 2.41.0 → 2.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/build/index.js +66 -8
- package/build/src/browser/connection.js +5 -0
- package/build/src/browser/factory.js +1 -0
- package/build/src/browser/playwright/connection.js +3 -0
- package/build/src/cli/headless-runner.js +55 -3
- package/build/src/commands/help.js +2 -0
- package/build/src/core/execution-engine.js +15 -0
- package/build/src/core/report/render-html-report.js +36 -6
- package/build/src/core/report/run-log-model.js +65 -0
- package/build/src/core/report/run-report-model.js +1 -0
- package/build/src/core/report/run-report-sections.js +15 -0
- package/build/src/core/tool-handlers/assert.js +45 -2
- package/build/src/core/tool-handlers/query-network.js +92 -0
- package/build/src/device/openai.js +25 -0
- package/build/src/modes/execution-mode.js +10 -1
- package/build/src/network/browser-capture.js +110 -0
- package/build/src/network/certificate-authority.js +205 -0
- package/build/src/network/certificate-install.js +242 -0
- package/build/src/network/local-proxy.js +287 -0
- package/build/src/network/network-capture.js +243 -0
- package/build/src/network/query-network.js +215 -0
- package/build/src/network/run-network-capture.js +107 -0
- package/build/src/prompts/execution.js +10 -6
- package/package.json +2 -1
- package/skill/droid-cua/SKILL.md +1 -1
- package/skill/droid-cua/references/authoring-tests.md +16 -0
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +10 -0
- package/skill/droid-cua/references/running-and-healing.md +4 -0
package/README.md
CHANGED
|
@@ -97,6 +97,7 @@ Desktop app support:
|
|
|
97
97
|
- **Desktop-first workflow** - Create, run, and manage tests from one app
|
|
98
98
|
- **Setup guidance** - Configure API access and platform prerequisites in the app
|
|
99
99
|
- **Device and simulator connection** - Connect Android targets and iOS simulators
|
|
100
|
+
- **Network certificate preparation** - Manage one active root CA and install it on a connected local simulator or supported emulator from the desktop app's Tools menu
|
|
100
101
|
- **Natural-language test creation** - Describe flows in plain English
|
|
101
102
|
- **Test management** - Create, edit, save, and rerun reusable scripts, including tests organized in nested project folders
|
|
102
103
|
- **Live execution logs** - Watch actions and progress as tests run
|
|
@@ -105,6 +106,22 @@ Desktop app support:
|
|
|
105
106
|
- **Run video export** - Generate and download a polished MP4 replay from a desktop run's screenshots and instruction timeline
|
|
106
107
|
- **Headless support** - Reuse scripts in CLI and automation workflows
|
|
107
108
|
|
|
109
|
+
**Network certificate preparation:** Use **Tools → Manage Certificates** to see the active CA, upload a single `.pem` containing a CA certificate and its matching unencrypted private key, generate a new Droid CA, or remove the active CA. Droid stores one active pair in `~/.droid-cua/network/`; upload copies the PEM material without changing the source file, and removal does not change device trust. Connect a local iOS Simulator or a rootable Android Emulator, then choose **Tools → Install Network Certificate**. This installs the active CA, generating a Droid CA if none exists. Devices prepared with a previous CA may need installation again. Droid may reapply Android system trust when a prepared emulator reconnects after a cold boot. Restart an already running Android app after installation so it loads the updated trust store. Android certificate setup supports capable rootable images through Android 16; Google Play images without root access and Android 17 are not supported.
|
|
110
|
+
|
|
111
|
+
**Network capture:** Enable **Network Capture** in desktop Settings or pass `--capture-network` for CLI tests. Local Chrome and Edge capture through Playwright with no certificate or proxy setup, using the same network queries and report cards as mobile. Capture covers the test browser context, including frames and popups, from run start. Previously loaded traffic is not recovered and existing pages are not automatically reloaded. CLI `--url` navigation happens after capture starts. Cloud browser capture, WebSocket messages and HAR export are not supported.
|
|
112
|
+
|
|
113
|
+
**Mobile setup:** Manually route a prepared local simulator or emulator to `127.0.0.1:8765` (`10.0.2.2:8765` from Android Emulator). Enable **Network Capture** in desktop Settings to keep the proxy forwarding while Droid is open and automatically capture every local mobile test, including each project or scenario case. Existing **Network Proxy** settings do not turn capture on; opt in to the new setting. For CLI tests, pass `--capture-network`; this starts the proxy and writes one JSONL log per test. `--network-proxy` remains a forwarding-only CLI flag. Droid prints or logs each capture path and a best-effort health summary. Desktop artifacts are in the project's results folder; CLI artifacts are beside `--report` output when enabled, or under `logs/`. A missing CA stops mobile capture before test actions. No observed traffic or failed HTTPS decryption is reported separately from test assertions, but capture health does not prove that all traffic was observed. WebSocket messages are unsupported. Manual proxy setup is the V1 workflow. Droid does not change device or Mac proxy settings; turn off manually configured routing when finished. If Droid exits while routing remains enabled, restart Droid or clear the proxy to restore connectivity.
|
|
114
|
+
|
|
115
|
+
Captures stop recording at 100 MiB per run, retaining a partial JSONL with a `CAPTURE_LIMIT` error. Traffic continues forwarding; live network queries report incomplete evidence. Captured bodies are limited to 1 MiB, both on the wire and after decompression. Compressed bodies that expand beyond that limit are omitted with `decoded-body-too-large`; this is incomplete evidence, not a missing response. The separate query evidence limit is 1,000,000 serialized characters. A capture limit or missing traffic is not proof that an application request never occurred.
|
|
116
|
+
|
|
117
|
+
For troubleshooting, “No traffic observed” points to routing or an app that did not send traffic; it does not prove an expected request was absent. HTTPS connections with no decrypted requests point to certificate trust or interception compatibility. A port-in-use error means another Droid instance or proxy already owns the listener. Stop that listener before starting the CLI. A capture-size or write failure retains only partial evidence. iOS network validation is deferred; ordinary iOS runs without capture do not require proxy or certificate preparation.
|
|
118
|
+
|
|
119
|
+
**Network questions and assertions:** With capture enabled, tests can ask Droid to inspect HTTP traffic and compare values across requests in natural language. For example: “Assert that the POST to `/pageview` after opening Home contains the `item_id` returned by `/home`.” The `query_network` tool searches by URL substring, method, status, flow IDs, request timestamps, or a starting step. It considers all matching exchanges in time order and sends only selected headers/bodies to one side call using the selected run model. Large results require a narrower search; they are not silently truncated. Optional observation windows wait the full duration, up to 30 seconds per query. Absence means only “no matching traffic observed during this window”; unavailable capture or insufficient evidence cannot establish absence. Desktop and HTML reports show one collapsed Network query card per call; expand it to see the question and answer. The agent receives structured flow references and capture diagnostics, while model details stay out of the timeline.
|
|
120
|
+
|
|
121
|
+
To inspect a saved capture without a model, run `node scripts/query-network.mjs path/to/network.jsonl '{"url_contains":"/pageview","method":"POST","sections":["request_body"]}'` from the repository. Select `request_headers`, `response_headers`, `request_body`, or `response_body`; use `sections: []` for metadata. Saved-log queries accept `after` and `before` timestamps, but not live step IDs or observation waits.
|
|
122
|
+
|
|
123
|
+
**Developer network capture:** `node scripts/network-capture-smoke.mjs` still offers a standalone proxy checkpoint. Leave desktop **Network Capture** off and omit CLI network flags while it owns `127.0.0.1:8765`. Press Ctrl+C when finished; use `--output path.jsonl` to choose the JSONL path. Request and response headers are written immediately, body records when streams finish or abort, binary bodies are omitted, and captured bodies are limited to 1 MiB on the wire and after decompression. Filtering is partial: JSONL captures may contain credentials, tokens, cookies, or other sensitive data. Treat them as private run artifacts.
|
|
124
|
+
|
|
108
125
|
---
|
|
109
126
|
|
|
110
127
|
<h2 id="how-it-works">🔧 How It Works</h2>
|
|
@@ -297,6 +314,8 @@ Supported CLI options include:
|
|
|
297
314
|
- `--execution-prompt-file`
|
|
298
315
|
- `--secrets`
|
|
299
316
|
- `--record`
|
|
317
|
+
- `--network-proxy` (local mobile sessions only; starts idle forwarding at `127.0.0.1:8765`)
|
|
318
|
+
- `--capture-network` (local mobile and Chrome/Edge test runs; writes a JSONL log for each test, using a proxy only for mobile)
|
|
300
319
|
- `--report`
|
|
301
320
|
- `--report-metadata <path>`
|
|
302
321
|
- `--no-cloud-report`
|
package/build/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import minimist from "minimist";
|
|
|
3
3
|
import dotenv from "dotenv";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { mkdir } from "fs/promises";
|
|
6
|
-
import { connectToDevice, disconnect as disconnectDevice, getDeviceInfo } from "./src/device/connection.js";
|
|
6
|
+
import { connectToDevice, disconnect as disconnectDevice, getDeviceInfo, getCurrentPlatform as getConnectedDevicePlatform } from "./src/device/connection.js";
|
|
7
7
|
import { connectToBrowser, disconnect as disconnectBrowser, getBrowserInfo, } from "./src/browser/connection.js";
|
|
8
8
|
import { setBrowserBackendId } from "./src/browser/factory.js";
|
|
9
9
|
import { normalizeBrowserInfoForSession } from "./src/browser/normalize.js";
|
|
@@ -45,9 +45,14 @@ import { ARTIFACT_READY_STATUSES } from "./src/device/cloud/loadmill-cloud/run-s
|
|
|
45
45
|
import { formatDuration } from "./src/core/report/run-log-model.js";
|
|
46
46
|
import { createBrowserSessionTeardown, withLoadmillCloudSession } from "./src/cli/loadmill-cloud-session.js";
|
|
47
47
|
import { createDesktopController, listDesktopApplications } from "./src/desktop/computer-use.js";
|
|
48
|
+
import { createRunNetworkCapture } from "./src/network/run-network-capture.js";
|
|
49
|
+
import { loadCertificateAuthority } from "./src/network/certificate-authority.js";
|
|
50
|
+
import { ensureCliAndroidCertificate, inspectNetworkDevice } from "./src/network/certificate-install.js";
|
|
48
51
|
dotenv.config();
|
|
52
|
+
let networkCapture = null;
|
|
49
53
|
process.once("SIGTERM", async () => {
|
|
50
54
|
process.exitCode = 143;
|
|
55
|
+
await networkCapture?.shutdown().catch(() => { });
|
|
51
56
|
// Registering a SIGTERM listener cancels Node's default terminate, so exit
|
|
52
57
|
// explicitly; await the /stop because a bare fire-and-forget fetch may not
|
|
53
58
|
// dispatch before exit. Capped so a slow server can't hang the exit.
|
|
@@ -386,6 +391,36 @@ async function main() {
|
|
|
386
391
|
if (!isHeadlessInstructionsRun && args["test-data"] != null) {
|
|
387
392
|
throw new Error("--test-data requires `droid-cua run <path>` or --instructions headless execution.");
|
|
388
393
|
}
|
|
394
|
+
if (args["network-proxy"] != null && args["network-proxy"] !== true) {
|
|
395
|
+
throw new Error("--network-proxy is a flag and does not take a value.");
|
|
396
|
+
}
|
|
397
|
+
if (args["network-proxy"] === true && (isBrowserTarget || isDesktopTarget || isCloudTarget)) {
|
|
398
|
+
throw new Error("--network-proxy is available only for local mobile sessions.");
|
|
399
|
+
}
|
|
400
|
+
if (args["capture-network"] != null && args["capture-network"] !== true) {
|
|
401
|
+
throw new Error("--capture-network is a flag and does not take a value.");
|
|
402
|
+
}
|
|
403
|
+
if (args["capture-network"] === true && (!isHeadlessInstructionsRun || isDesktopTarget || isCloudTarget)) {
|
|
404
|
+
throw new Error("--capture-network requires a local mobile or browser test run.");
|
|
405
|
+
}
|
|
406
|
+
const captureNetwork = args["capture-network"] === true;
|
|
407
|
+
if (captureNetwork && !isBrowserTarget)
|
|
408
|
+
await loadCertificateAuthority();
|
|
409
|
+
networkCapture = args["network-proxy"] === true || captureNetwork ? createRunNetworkCapture() : null;
|
|
410
|
+
if (captureNetwork) {
|
|
411
|
+
process.once("SIGINT", async () => {
|
|
412
|
+
await networkCapture?.shutdown().catch(() => { });
|
|
413
|
+
process.exit(130);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (networkCapture && !isBrowserTarget) {
|
|
417
|
+
try {
|
|
418
|
+
await networkCapture.startProxy();
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
throw new Error(`Droid network proxy could not start: ${error.message}`, { cause: error });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
389
424
|
let preloadedHeadlessConfig = null;
|
|
390
425
|
let preloadedHeadlessTarget = null;
|
|
391
426
|
let headlessInstructionRuns = [];
|
|
@@ -520,7 +555,7 @@ async function main() {
|
|
|
520
555
|
}
|
|
521
556
|
else if (isBrowserTarget) {
|
|
522
557
|
targetId = await connectToBrowser({
|
|
523
|
-
url: startupUrl,
|
|
558
|
+
url: captureNetwork ? undefined : startupUrl,
|
|
524
559
|
headless: true,
|
|
525
560
|
browserFamily: targetConfig.browserFamily,
|
|
526
561
|
sessionMode: targetConfig.sessionMode
|
|
@@ -767,6 +802,20 @@ async function main() {
|
|
|
767
802
|
}
|
|
768
803
|
else {
|
|
769
804
|
targetId = await connectToDevice(avdName, platform);
|
|
805
|
+
if (captureNetwork) {
|
|
806
|
+
const connectedPlatform = getConnectedDevicePlatform();
|
|
807
|
+
if (connectedPlatform === "android") {
|
|
808
|
+
if (!/^emulator-\d+$/.test(targetId))
|
|
809
|
+
throw new Error("--capture-network supports Android Emulators, not physical Android devices.");
|
|
810
|
+
const ca = await loadCertificateAuthority();
|
|
811
|
+
const trust = await ensureCliAndroidCertificate(targetId, ca);
|
|
812
|
+
if (trust.changed)
|
|
813
|
+
console.log(`Reapplied Droid's network certificate on ${targetId}.`);
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
await inspectNetworkDevice({ platform: "ios", deviceId: targetId });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
770
819
|
deviceInfo = await getDeviceInfo(targetId);
|
|
771
820
|
console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
772
821
|
console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
@@ -837,12 +886,15 @@ async function main() {
|
|
|
837
886
|
desktopController,
|
|
838
887
|
isCloudTarget,
|
|
839
888
|
runTarget: { isBrowserTarget, isDesktopTarget, isCloudTarget },
|
|
840
|
-
report: reportConfig
|
|
889
|
+
report: reportConfig,
|
|
890
|
+
networkCapture: captureNetwork ? networkCapture : null,
|
|
891
|
+
initialBrowserUrl: captureNetwork && isBrowserTarget ? startupUrl : null
|
|
841
892
|
});
|
|
842
893
|
exitCode = result.exitCode;
|
|
843
894
|
succeeded = true;
|
|
844
895
|
}
|
|
845
896
|
finally {
|
|
897
|
+
await networkCapture?.shutdown();
|
|
846
898
|
await droidReportUploadManager.drain();
|
|
847
899
|
if (lambdaTestSessionConnected) {
|
|
848
900
|
console.log("Ending LambdaTest session...");
|
|
@@ -911,12 +963,18 @@ async function main() {
|
|
|
911
963
|
reviseTestScript: runtimeContext.openAi.reviseTestScript,
|
|
912
964
|
});
|
|
913
965
|
// Otherwise, start interactive Ink shell
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
966
|
+
try {
|
|
967
|
+
await startInkShell(session, engine, {
|
|
968
|
+
appContextPath: appContextPath ? path.resolve(appContextPath) : null,
|
|
969
|
+
runtimeContext,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
finally {
|
|
973
|
+
await networkCapture?.shutdown();
|
|
974
|
+
}
|
|
918
975
|
}
|
|
919
|
-
main().catch((error) => {
|
|
976
|
+
main().catch(async (error) => {
|
|
977
|
+
await networkCapture?.shutdown().catch(() => { });
|
|
920
978
|
const message = error instanceof Error ? error.message : String(error);
|
|
921
979
|
console.error(`\nTest failed: ${message}`);
|
|
922
980
|
process.exit(1);
|
|
@@ -35,6 +35,11 @@ export async function closeCurrentTab(browserSessionId) {
|
|
|
35
35
|
return currentBackend.closeCurrentTab(browserSessionId);
|
|
36
36
|
}
|
|
37
37
|
export { getCurrentPlatform };
|
|
38
|
+
export function getNetworkCaptureContext(browserSessionId) {
|
|
39
|
+
if (!currentBackend?.getNetworkCaptureContext)
|
|
40
|
+
throw new Error("Network capture supports local Chrome and Edge sessions only.");
|
|
41
|
+
return currentBackend.getNetworkCaptureContext(browserSessionId);
|
|
42
|
+
}
|
|
38
43
|
export async function disconnect() {
|
|
39
44
|
if (currentBackend?.disconnect) {
|
|
40
45
|
await currentBackend.disconnect();
|
|
@@ -16,6 +16,7 @@ function buildPlaywrightBackend() {
|
|
|
16
16
|
closeCurrentTab: playwrightConnection.closeCurrentTab,
|
|
17
17
|
handleModelAction: playwrightActions.handleModelAction,
|
|
18
18
|
disconnect: playwrightConnection.disconnect,
|
|
19
|
+
getNetworkCaptureContext: playwrightConnection.getNetworkCaptureContext,
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
22
|
function buildWebDriverBackend() {
|
|
@@ -373,6 +373,9 @@ export async function connectToBrowser(targetOrOptions) {
|
|
|
373
373
|
});
|
|
374
374
|
return sessionId;
|
|
375
375
|
}
|
|
376
|
+
export function getNetworkCaptureContext(browserSessionId) {
|
|
377
|
+
return getActiveBrowserSession(browserSessionId).context;
|
|
378
|
+
}
|
|
376
379
|
export async function getBrowserInfo(browserSessionId) {
|
|
377
380
|
const session = getActiveBrowserSession(browserSessionId);
|
|
378
381
|
const activePage = getActivePage(session);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { mkdir, writeFile } from "fs/promises";
|
|
3
|
-
import { getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "../browser/connection.js";
|
|
3
|
+
import { getNetworkCaptureContext, navigateToUrl as navigateBrowserToUrl, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "../browser/connection.js";
|
|
4
|
+
import { normalizeBrowserInfoForSession } from "../browser/normalize.js";
|
|
5
|
+
import { updateBrowserSessionInfo } from "../core/tool-handlers/browser-session.js";
|
|
4
6
|
import { handleModelAction as handleBrowserModelAction } from "../browser/actions.js";
|
|
5
7
|
import { ExecutionEngine } from "../core/execution-engine.js";
|
|
6
8
|
import { buildExecutionModePrompt } from "../core/prompts.js";
|
|
@@ -17,6 +19,7 @@ import { clearLoadmillReportMetadata } from "./loadmill-report-metadata.js";
|
|
|
17
19
|
import { formatHeadlessTestSourceLabel } from "./headless-test-source-label.js";
|
|
18
20
|
import { buildCliRunReportModel, createCapturingAddOutput, createCliAggregateRunReport, createCliRunReport, renderCliCloudReport, writeCliRunReportArtifact, } from "./headless-report.js";
|
|
19
21
|
import { createRefuseReconnectConnectToBrowser } from "../desktop-shared/connection-predicates.js";
|
|
22
|
+
import { describeNetworkCapture, hasNetworkCaptureIssue } from "../network/run-network-capture.js";
|
|
20
23
|
function sanitizePathSegment(value) {
|
|
21
24
|
return String(value || "test")
|
|
22
25
|
.replace(/\.dcua$/i, "")
|
|
@@ -29,6 +32,15 @@ function createHeadlessRunId(index = 0) {
|
|
|
29
32
|
const suffix = index > 0 ? `-${index + 1}` : "";
|
|
30
33
|
return `run-${Date.now()}${suffix}`;
|
|
31
34
|
}
|
|
35
|
+
function networkLogPath(runId, report) {
|
|
36
|
+
if (!report?.enabled)
|
|
37
|
+
return path.resolve("logs", `network-${runId}.jsonl`);
|
|
38
|
+
if (!report.path)
|
|
39
|
+
return path.resolve(`network-${runId}.jsonl`);
|
|
40
|
+
const resolved = path.resolve(report.path);
|
|
41
|
+
const directory = /\.html?$/i.test(report.path) ? path.dirname(resolved) : resolved;
|
|
42
|
+
return path.join(directory, `network-${runId}.jsonl`);
|
|
43
|
+
}
|
|
32
44
|
function logPendingScreenshotUploads(uploadManager, uploadSession, subject) {
|
|
33
45
|
console.log('Verifying no pending upload exists');
|
|
34
46
|
const pendingCount = uploadManager.getPendingCount?.(uploadSession) ?? 0;
|
|
@@ -167,8 +179,27 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
|
|
|
167
179
|
}
|
|
168
180
|
return engineOptions;
|
|
169
181
|
}
|
|
170
|
-
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
|
|
182
|
+
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
|
|
171
183
|
let executionMode = null;
|
|
184
|
+
let captureStarted = false;
|
|
185
|
+
const finishCapture = async () => {
|
|
186
|
+
if (!captureStarted)
|
|
187
|
+
return;
|
|
188
|
+
captureStarted = false;
|
|
189
|
+
try {
|
|
190
|
+
const result = await networkCapture.stopCapture();
|
|
191
|
+
if (result) {
|
|
192
|
+
const line = `${describeNetworkCapture(result)} Network log: ${result.path}`;
|
|
193
|
+
if (hasNetworkCaptureIssue(result))
|
|
194
|
+
console.warn(`Warning: ${line}`);
|
|
195
|
+
else
|
|
196
|
+
console.log(line);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
console.warn(`Warning: network capture could not finish: ${error.message}`);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
172
203
|
let loadmillSessionUser = null;
|
|
173
204
|
let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
|
|
174
205
|
const buildSegmentUsageProperties = () => ({
|
|
@@ -254,6 +285,23 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
254
285
|
});
|
|
255
286
|
const engine = new ExecutionEngine(session, engineOptions);
|
|
256
287
|
executionMode = new ExecutionMode(session, engine, instructions, true);
|
|
288
|
+
if (networkCapture) {
|
|
289
|
+
try {
|
|
290
|
+
const browserContext = isBrowserTarget ? getNetworkCaptureContext(session.deviceId) : null;
|
|
291
|
+
const capturePath = await networkCapture.startCapture(networkLogPath(runId, report), { browserContext });
|
|
292
|
+
console.log(`Network log: ${capturePath}`);
|
|
293
|
+
captureStarted = true;
|
|
294
|
+
engine.networkQuery = networkCapture.query;
|
|
295
|
+
engine.toolCapabilities.queryNetwork = true;
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
throw new Error(`Network capture setup failed: ${error.message}`, { cause: error });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (initialBrowserUrl) {
|
|
302
|
+
await navigateBrowserToUrl(session.deviceId, initialBrowserUrl);
|
|
303
|
+
updateBrowserSessionInfo(session, normalizeBrowserInfoForSession(await getBrowserInfo(session.deviceId)));
|
|
304
|
+
}
|
|
257
305
|
const result = await executionMode.execute({
|
|
258
306
|
runId,
|
|
259
307
|
addOutput,
|
|
@@ -264,6 +312,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
264
312
|
// Inline screenshots as data URLs so the HTML report is self-contained.
|
|
265
313
|
...(reportEnabled ? { includeScreenshotDataUrl: true } : {}),
|
|
266
314
|
});
|
|
315
|
+
await finishCapture();
|
|
267
316
|
const stats = executionMode.stats || {};
|
|
268
317
|
const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
|
|
269
318
|
await headlessDebug.endExecutionSession(runId, {
|
|
@@ -363,6 +412,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
363
412
|
};
|
|
364
413
|
}
|
|
365
414
|
catch (error) {
|
|
415
|
+
await finishCapture();
|
|
366
416
|
const message = error instanceof Error ? error.message : "Failed to start execution.";
|
|
367
417
|
const stats = executionMode?.stats || {};
|
|
368
418
|
const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
|
|
@@ -468,7 +518,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
468
518
|
throw error;
|
|
469
519
|
}
|
|
470
520
|
}
|
|
471
|
-
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget = false, desktopController = null, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
|
|
521
|
+
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget = false, desktopController = null, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
|
|
472
522
|
await clearLoadmillReportMetadata(report?.metadataPath);
|
|
473
523
|
const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
|
|
474
524
|
const runConfiguration = buildDroidRunConfiguration({
|
|
@@ -546,6 +596,8 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
546
596
|
runConfiguration,
|
|
547
597
|
cloudUploadSession,
|
|
548
598
|
uploadManager,
|
|
599
|
+
networkCapture,
|
|
600
|
+
initialBrowserUrl: index === 0 ? initialBrowserUrl : null,
|
|
549
601
|
});
|
|
550
602
|
aggregateStats.durationMs += result.durationMs ?? 0;
|
|
551
603
|
aggregateStats.instructionsTotal += result.instructionsTotal ?? 0;
|
|
@@ -55,6 +55,8 @@ export async function handleHelp(args, session, context) {
|
|
|
55
55
|
addOutput({ type: 'info', text: ' --record Record screenshots during execution' });
|
|
56
56
|
addOutput({ type: 'info', text: ' --explain Explain non-obvious decisions and completion evidence' });
|
|
57
57
|
addOutput({ type: 'info', text: ' --report [path] Save a self-contained HTML report locally' });
|
|
58
|
+
addOutput({ type: 'info', text: ' --network-proxy Forward local mobile traffic through 127.0.0.1:8765 without capture' });
|
|
59
|
+
addOutput({ type: 'info', text: ' --capture-network Capture local mobile or browser test traffic to one JSONL log per test' });
|
|
58
60
|
addOutput({ type: 'info', text: ' --no-cloud-report Skip uploading the HTML report and screenshots to Loadmill' });
|
|
59
61
|
addOutput({ type: 'info', text: ' --report-metadata <path> Write uploaded Loadmill report metadata as JSON' });
|
|
60
62
|
addOutput({ type: 'info', text: ' --debug Enable structured JSONL debug artifacts' });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { answerNetworkQuestion, handleQueryNetworkToolCall, NETWORK_QUERY_FAILURE_MESSAGE } from "./tool-handlers/query-network.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { getScreenshotAsBase64, getCurrentPlatform } from "../device/connection.js";
|
|
3
4
|
import { closeCurrentTab as closeCurrentBrowserTab, getBrowserInfo, navigateToUrl as navigateBrowserToUrl } from "../browser/connection.js";
|
|
@@ -246,6 +247,8 @@ export class ExecutionEngine {
|
|
|
246
247
|
this.handleModelAction = options.handleModelAction || handleModelAction;
|
|
247
248
|
this.closeKeyboard = options.closeKeyboard || closeKeyboard;
|
|
248
249
|
this.sendCUARequest = options.sendCUARequest || sendCUARequest;
|
|
250
|
+
this.networkQuery = options.networkQuery || null;
|
|
251
|
+
this.answerNetworkQuestion = options.answerNetworkQuestion || answerNetworkQuestion;
|
|
249
252
|
this.reviseTestScript = options.reviseTestScript || null;
|
|
250
253
|
this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
|
|
251
254
|
this.getBrowserInfo = options.getBrowserInfo || getBrowserInfo;
|
|
@@ -323,6 +326,8 @@ export class ExecutionEngine {
|
|
|
323
326
|
switch (toolName) {
|
|
324
327
|
case "preview_click":
|
|
325
328
|
return await handlePreviewClick(handlerArgs);
|
|
329
|
+
case "query_network":
|
|
330
|
+
return await handleQueryNetworkToolCall(handlerArgs);
|
|
326
331
|
case "assert":
|
|
327
332
|
return await handleAssertionToolCall(handlerArgs);
|
|
328
333
|
case "navigate":
|
|
@@ -606,6 +611,7 @@ export class ExecutionEngine {
|
|
|
606
611
|
...extra
|
|
607
612
|
});
|
|
608
613
|
let refreshBeforeNextComputerAction = false;
|
|
614
|
+
let consecutiveNetworkQueryFailures = 0;
|
|
609
615
|
let observationOnlyTurnsSinceLastAction = 0;
|
|
610
616
|
let observationOnlyRecoverySent = false;
|
|
611
617
|
let pendingObservationOnlyRecoveryMessage = null;
|
|
@@ -752,6 +758,15 @@ export class ExecutionEngine {
|
|
|
752
758
|
summary.error = toolResult.error;
|
|
753
759
|
break;
|
|
754
760
|
}
|
|
761
|
+
if (actionableItem.name === "query_network") {
|
|
762
|
+
consecutiveNetworkQueryFailures = toolResult.queryFailed ? consecutiveNetworkQueryFailures + 1 : 0;
|
|
763
|
+
if (consecutiveNetworkQueryFailures >= 3) {
|
|
764
|
+
summary.failed = true;
|
|
765
|
+
summary.error = NETWORK_QUERY_FAILURE_MESSAGE;
|
|
766
|
+
addOutput({ type: "error", text: summary.error, eventType: "error", ...eventMeta() });
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
755
770
|
if (toolResult.kind === "retry_instruction") {
|
|
756
771
|
summary.retryInstruction = true;
|
|
757
772
|
newResponseId = null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// HTML document that reproduces the Desktop App's run-details view. Pure: no I/O and
|
|
3
3
|
// no external references — screenshots must already be inlined as data URLs in
|
|
4
4
|
// `event.payload.screenshotDataUrl`. Use generate-html-report.js to inline them.
|
|
5
|
-
import { assertionData, formatDuration, hasNearbyAssertionCard, isAssistantAssertionSummary, isDuplicateAssertionLine, isHiddenExecutionLogEvent, runSummaryData, stepCompletionData, stepCompletionReasonTooltip, } from "./run-log-model.js";
|
|
5
|
+
import { assertionData, networkCardData, isNetworkCaptureStarted, formatDuration, hasNearbyAssertionCard, isAssistantAssertionSummary, isDuplicateAssertionLine, isHiddenExecutionLogEvent, runSummaryData, stepCompletionData, stepCompletionReasonTooltip, } from "./run-log-model.js";
|
|
6
6
|
import { buildRunReportSections, capitalizeReportLine, cloudProviderId, cloudProviderLabel, cloudStatus, } from "./run-report-sections.js";
|
|
7
7
|
import { LOADMILL_MARK_PATHS } from "../../brand/loadmill-mark.js";
|
|
8
8
|
// ---------- text / value formatting (mirrors the Desktop App) ----------
|
|
@@ -118,6 +118,7 @@ function loadmillIcon() {
|
|
|
118
118
|
return `<svg class="dc-icon" data-icon="loadmill-mark" aria-hidden="true" viewBox="0 0 1035 1035">${paths}</svg>`;
|
|
119
119
|
}
|
|
120
120
|
const ICONS = {
|
|
121
|
+
network: icon("network", `<rect x="9" y="2" width="6" height="6" rx="1"/><path d="M12 8v4M5 16v-4h14v4"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/>`),
|
|
121
122
|
arrowUpDown: icon("arrow-up-down", `<path d="m21 16-4 4-4-4"/><path d="M17 20V4"/><path d="m3 8 4-4 4 4"/><path d="M7 4v16"/>`),
|
|
122
123
|
bookOpen: icon("book-open", `<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>`),
|
|
123
124
|
botMessageSquare: icon("bot-message-square", `<path d="M12 6V2H8"/><path d="m8 18-4 4V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2Z"/><path d="M2 12h2"/><path d="M9 11v2"/><path d="M15 11v2"/><path d="M20 12h2"/>`),
|
|
@@ -224,6 +225,9 @@ function renderEvent(event, shots) {
|
|
|
224
225
|
if (event.kind === "action") {
|
|
225
226
|
return `<div class="dc-line dc-action">${actionIcon(event)}<span>${escapeHtml(event.text)}</span>${screenshotImg(event, shots)}</div>`;
|
|
226
227
|
}
|
|
228
|
+
if (isNetworkCaptureStarted(event)) {
|
|
229
|
+
return `<div class="dc-line">${ICONS.network}<span>Network capture started</span></div>`;
|
|
230
|
+
}
|
|
227
231
|
if (event.eventType === "screenshot_captured") {
|
|
228
232
|
return `<div class="dc-line">${ICONS.camera}<span>${escapeHtml(event.text)}</span>${screenshotImg(event, shots)}</div>`;
|
|
229
233
|
}
|
|
@@ -287,6 +291,20 @@ function renderLoadmillCard(events) {
|
|
|
287
291
|
<div class="dc-special-body">${renderedLines.join("")}</div>
|
|
288
292
|
</details>`;
|
|
289
293
|
}
|
|
294
|
+
function renderNetworkCard(lines) {
|
|
295
|
+
const { outcome, question, filters, answer } = networkCardData(lines);
|
|
296
|
+
const resultHtml = answer.structured
|
|
297
|
+
? `<details class="dc-network-data"><summary>Response data</summary><pre>${escapeHtml(answer.text)}</pre></details>`
|
|
298
|
+
: `<div class="dc-network-answer">${renderLinkedText(answer.text)}</div>`;
|
|
299
|
+
return `<details class="dc-special dc-network">
|
|
300
|
+
<summary><span class="dc-special-title">${ICONS.network}Network query</span><span>${escapeHtml(outcome)}</span></summary>
|
|
301
|
+
<div class="dc-special-body">
|
|
302
|
+
<dl class="dc-network-filters">${filters.map(({ label, value }) => `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`).join("")}</dl>
|
|
303
|
+
${question ? `<div><strong>Question</strong><div>${renderLinkedText(question)}</div></div>` : ""}
|
|
304
|
+
${answer.text ? `<div><strong>Result</strong>${resultHtml}</div>` : ""}
|
|
305
|
+
</div>
|
|
306
|
+
</details>`;
|
|
307
|
+
}
|
|
290
308
|
function renderTimeline(events, shots, warnings = []) {
|
|
291
309
|
const html = buildRunReportSections(buildVisibleEvents(events))
|
|
292
310
|
.map((item) => {
|
|
@@ -294,6 +312,8 @@ function renderTimeline(events, shots, warnings = []) {
|
|
|
294
312
|
return renderAppContextCard(item.line);
|
|
295
313
|
if (item.kind === "cloud")
|
|
296
314
|
return renderCloudCard(item.lines);
|
|
315
|
+
if (item.kind === "network")
|
|
316
|
+
return renderNetworkCard(item.lines);
|
|
297
317
|
if (item.kind === "loadmill")
|
|
298
318
|
return renderLoadmillCard(item.lines);
|
|
299
319
|
return renderEvent(item.line, shots);
|
|
@@ -438,16 +458,26 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
|
|
|
438
458
|
.dc-pill-starting { border-color: #a5b4fc; background: #e0e7ff; color: #4338ca; }
|
|
439
459
|
.dc-pill-running { border-color: #7dd3fc; background: #e0f2fe; color: #0369a1; }
|
|
440
460
|
.dc-special { width: 100%; border: 1px solid; border-radius: 12px; padding: 11px 14px; }
|
|
441
|
-
.dc-special summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; list-style: none; }
|
|
442
|
-
.dc-special summary::-webkit-details-marker { display: none; }
|
|
443
|
-
.dc-special summary::before { content: "›"; margin-right: 4px; color: currentColor; font-size: 18px; line-height: 1; transform-origin: center; }
|
|
444
|
-
.dc-special[open] summary::before { transform: rotate(90deg); }
|
|
461
|
+
.dc-special > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; list-style: none; }
|
|
462
|
+
.dc-special > summary::-webkit-details-marker { display: none; }
|
|
463
|
+
.dc-special > summary::before { content: "›"; margin-right: 4px; color: currentColor; font-size: 18px; line-height: 1; transform-origin: center; }
|
|
464
|
+
.dc-special[open] > summary::before { transform: rotate(90deg); }
|
|
445
465
|
.dc-special-title { display: inline-flex; flex: 1; align-items: center; gap: 5px; font-size: 13px; font-weight: 600; }
|
|
446
466
|
.dc-special-body { margin-top: 9px; display: flex; flex-direction: column; gap: 5px; color: #334155; }
|
|
447
467
|
.dc-context { border-color: #ddd6fe; background: #f5f3ff; color: #4c1d95; }
|
|
448
468
|
.dc-context pre { margin: 10px 0 0; border: 1px solid #ede9fe; border-radius: 8px; background: rgba(255,255,255,.75); padding: 8px 10px; color: #334155; font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; }
|
|
449
469
|
.dc-cloud { border-color: #c7d2fe; background: #eef2ff; color: #312e81; }
|
|
450
|
-
.dc-
|
|
470
|
+
.dc-network { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
|
|
471
|
+
.dc-network > summary { flex-wrap: wrap; }
|
|
472
|
+
.dc-network .dc-special-body { gap: 12px; }
|
|
473
|
+
.dc-network-filters { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; margin: 0; }
|
|
474
|
+
.dc-network-filters dt { color: #64748b; }
|
|
475
|
+
.dc-network-filters dd { margin: 0; }
|
|
476
|
+
.dc-network-answer { white-space: pre-wrap; }
|
|
477
|
+
.dc-network-data { border: 1px solid #bae6fd; border-radius: 6px; padding: 8px; background: rgba(255,255,255,.6); }
|
|
478
|
+
.dc-network-data > summary { display: list-item; list-style-position: inside; cursor: pointer; }
|
|
479
|
+
.dc-network-data pre { max-height: 288px; overflow: auto; white-space: pre-wrap; font-size: 12px; }
|
|
480
|
+
.dc-network, .dc-loadmill { border-color: #bae6fd; background: #f0f9ff; color: #075985; }
|
|
451
481
|
.dc-empty { color: #64748b; }
|
|
452
482
|
@keyframes dc-spin { to { transform: rotate(360deg); } }
|
|
453
483
|
`.trim();
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// Shared by the Desktop App (log-utils.ts re-exports these) and the HTML report renderer.
|
|
3
3
|
// No I/O, no React, no side effects.
|
|
4
4
|
import { classifyCaptureSource } from "../capture-sources.js";
|
|
5
|
+
export function isNetworkCaptureStarted(line) {
|
|
6
|
+
return (line.kind === "system" || line.kind === "info") &&
|
|
7
|
+
/^Network capture started(?::|$)/.test(line.text || "");
|
|
8
|
+
}
|
|
5
9
|
export function captureSourceOf(line) {
|
|
6
10
|
const captureSource = line.payload?.captureSource;
|
|
7
11
|
return typeof captureSource === "string" && captureSource.trim().length > 0 ? captureSource : null;
|
|
@@ -190,3 +194,64 @@ export function isDuplicateAssertionLine(event, previous) {
|
|
|
190
194
|
return false;
|
|
191
195
|
return current.assertion === prev.assertion && current.passed === prev.passed;
|
|
192
196
|
}
|
|
197
|
+
// Shared compact outcome for the desktop and HTML network cards.
|
|
198
|
+
export function networkQuerySummary(result) {
|
|
199
|
+
if (!result)
|
|
200
|
+
return "Searching…";
|
|
201
|
+
if (result.status === "matched") {
|
|
202
|
+
return `${result.match_count} matching ${result.match_count === 1 ? "request" : "requests"}`;
|
|
203
|
+
}
|
|
204
|
+
if (result.status === "no_matches") {
|
|
205
|
+
const duration = result.observation?.duration_ms;
|
|
206
|
+
return `No matches${duration ? ` in ${duration / 1000}s` : ""}${result.absence_supported ? "" : " · inconclusive"}`;
|
|
207
|
+
}
|
|
208
|
+
return result.status === "too_broad" ? "Narrow the search" : "Query failed";
|
|
209
|
+
}
|
|
210
|
+
// Presentation only: keep saved evidence intact while making JSON answers readable.
|
|
211
|
+
function networkAnswerPresentation(answer) {
|
|
212
|
+
// Some providers prefix a JSON body with a separate flow-reference object.
|
|
213
|
+
const candidate = answer.trim().replace(/^\{"ref":"[^"\\]+"\}\s+(?=\{)/, "");
|
|
214
|
+
try {
|
|
215
|
+
const value = JSON.parse(candidate);
|
|
216
|
+
if (!value || typeof value !== "object")
|
|
217
|
+
return { text: answer, structured: false };
|
|
218
|
+
if (typeof value.body === "string") {
|
|
219
|
+
try {
|
|
220
|
+
value.body = JSON.parse(value.body);
|
|
221
|
+
}
|
|
222
|
+
catch { /* Keep non-JSON bodies as text. */ }
|
|
223
|
+
}
|
|
224
|
+
return { text: JSON.stringify(value, null, 2), structured: true };
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return { text: answer, structured: false };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
export function networkCardData(lines) {
|
|
231
|
+
const call = lines.find((line) => line.eventType === "tool_call");
|
|
232
|
+
const response = lines.find((line) => line.eventType === "tool_result");
|
|
233
|
+
const query = call?.payload?.arguments ?? {};
|
|
234
|
+
const result = response?.payload?.result;
|
|
235
|
+
const filters = [];
|
|
236
|
+
for (const [key, label] of [
|
|
237
|
+
["method", "Method"], ["url_contains", "URL contains"], ["status", "Status"],
|
|
238
|
+
["from_step", "From step"], ["after", "After"], ["before", "Before"],
|
|
239
|
+
]) {
|
|
240
|
+
if (query[key] != null)
|
|
241
|
+
filters.push({ label, value: String(query[key]) });
|
|
242
|
+
}
|
|
243
|
+
if (query.flow_ids?.length)
|
|
244
|
+
filters.push({ label: "Flow IDs", value: query.flow_ids.join(", ") });
|
|
245
|
+
if (query.observe_ms)
|
|
246
|
+
filters.push({ label: "Observe", value: `${query.observe_ms / 1000}s` });
|
|
247
|
+
if (Array.isArray(query.sections)) {
|
|
248
|
+
filters.push({ label: "Inspect", value: query.sections.length ? query.sections.map((section) => section.replaceAll("_", " ")).join(", ") : "Metadata" });
|
|
249
|
+
}
|
|
250
|
+
const answer = result?.answer || result?.message || response?.text?.replace(/^Network result:\s*/, "") || "";
|
|
251
|
+
return {
|
|
252
|
+
outcome: networkQuerySummary(result),
|
|
253
|
+
question: query.question || call?.text?.replace(/^Network query:\s*/, "") || "",
|
|
254
|
+
filters,
|
|
255
|
+
answer: networkAnswerPresentation(answer),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -91,9 +91,24 @@ function findStartingCloudSection(sections, providerId) {
|
|
|
91
91
|
}
|
|
92
92
|
export function buildRunReportSections(logs) {
|
|
93
93
|
const sections = [];
|
|
94
|
+
const networkQueries = new Map();
|
|
94
95
|
let index = 0;
|
|
95
96
|
while (index < logs.length) {
|
|
96
97
|
const current = logs[index];
|
|
98
|
+
if (current.payload?.toolName === "query_network") {
|
|
99
|
+
const callId = current.payload.callId;
|
|
100
|
+
const key = JSON.stringify([current.runId, callId]);
|
|
101
|
+
let section = callId ? networkQueries.get(key) : null;
|
|
102
|
+
if (!section) {
|
|
103
|
+
section = { kind: "network", lines: [] };
|
|
104
|
+
sections.push(section);
|
|
105
|
+
if (callId)
|
|
106
|
+
networkQueries.set(key, section);
|
|
107
|
+
}
|
|
108
|
+
section.lines.push(current);
|
|
109
|
+
index += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
97
112
|
if (isAppContextLine(current)) {
|
|
98
113
|
sections.push({ kind: "app-context", line: current });
|
|
99
114
|
index += 1;
|