@loadmill/droid-cua 2.28.0 → 2.29.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 CHANGED
@@ -291,7 +291,7 @@ HTML report (`--report`):
291
291
  - Writes a self-contained HTML report of the run — the same Run Details view the desktop app shows, with screenshots inlined — that you can share and open in any browser.
292
292
  - With no value it saves `<test>--<date>--<time>.html` (e.g. `login--2026-06-03--16-09-20.html`) in the current directory; pass `--report <file>` to pick a filename or `--report <dir>` to pick a folder.
293
293
  - In the desktop app the same report is available from a run's **Share → Download HTML report** button.
294
- - Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9 browser replay locally, then prompts for a save location using `<test>--<date>--<time>--test-replay.mp4` as the suggested filename.
294
+ - Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9 browser replay locally, then prompts for a save location using `<test>--<date>--<time>--test-replay.mp4` as the suggested filename. While rendering, a compact **Background tasks** card shows progress for **Generating test replay**; it can be minimized to a task-count card or canceled without opening the save dialog.
295
295
  - Authenticated headless runs upload their HTML report and screenshots to Loadmill by default. Use `--no-cloud-report` to disable that artifact upload. The basic Droid run row is still reported to Loadmill.
296
296
 
297
297
  ```sh
@@ -3,6 +3,8 @@
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
5
  import { assertionData, formatDuration, hasNearbyAssertionCard, isAssistantAssertionSummary, isDuplicateAssertionLine, isHiddenExecutionLogEvent, runSummaryData, stepCompletionData, stepCompletionReasonTooltip, } from "./run-log-model.js";
6
+ import { buildRunReportSections, capitalizeReportLine, cloudProviderId, cloudProviderLabel, cloudStatus, } from "./run-report-sections.js";
7
+ import { LOADMILL_MARK_PATHS } from "../../brand/loadmill-mark.js";
6
8
  // ---------- text / value formatting (mirrors the Desktop App) ----------
7
9
  function escapeHtml(value) {
8
10
  return String(value ?? "")
@@ -12,6 +14,22 @@ function escapeHtml(value) {
12
14
  .replace(/"/g, "&quot;")
13
15
  .replace(/'/g, "&#39;");
14
16
  }
17
+ function renderLinkedText(value) {
18
+ const text = String(value ?? "");
19
+ const pattern = /https?:\/\/[^\s]+/g;
20
+ let cursor = 0;
21
+ let html = "";
22
+ for (const match of text.matchAll(pattern)) {
23
+ const start = match.index ?? 0;
24
+ const raw = match[0];
25
+ const trailing = raw.match(/[).,;:!?]+$/)?.[0] ?? "";
26
+ const url = trailing ? raw.slice(0, -trailing.length) : raw;
27
+ html += escapeHtml(text.slice(cursor, start));
28
+ html += `<a href="${escapeHtml(url)}" target="_blank" rel="noreferrer">${escapeHtml(url)}</a>${escapeHtml(trailing)}`;
29
+ cursor = start + raw.length;
30
+ }
31
+ return html + escapeHtml(text.slice(cursor));
32
+ }
15
33
  function formatRunTimestamp(iso) {
16
34
  if (!iso)
17
35
  return "—";
@@ -95,10 +113,16 @@ function screenshotImg(event, shots) {
95
113
  function icon(name, shapes) {
96
114
  return `<svg class="dc-icon" data-icon="${name}" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${shapes}</svg>`;
97
115
  }
116
+ function loadmillIcon() {
117
+ const paths = LOADMILL_MARK_PATHS.map(({ d, fill }) => `<path d="${d}" fill="${fill}"/>`).join("");
118
+ return `<svg class="dc-icon" data-icon="loadmill-mark" aria-hidden="true" viewBox="0 0 1035 1035">${paths}</svg>`;
119
+ }
98
120
  const ICONS = {
99
121
  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
+ 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"/>`),
100
123
  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"/>`),
101
124
  camera: icon("camera", `<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>`),
125
+ cloud: icon("cloud", `<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>`),
102
126
  compass: icon("compass", `<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/><circle cx="12" cy="12" r="10"/>`),
103
127
  flag: icon("flag", `<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/>`),
104
128
  keyboard: icon("keyboard", `<path d="M10 8h.01"/><path d="M12 12h.01"/><path d="M14 8h.01"/><path d="M16 12h.01"/><path d="M18 8h.01"/><path d="M6 8h.01"/><path d="M7 16h10"/><path d="M8 12h.01"/><rect width="20" height="16" x="2" y="4" rx="2"/>`),
@@ -109,6 +133,7 @@ const ICONS = {
109
133
  rotateCcw: icon("rotate-ccw", `<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>`),
110
134
  squareX: icon("square-x", `<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>`),
111
135
  timer: icon("timer", `<line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/>`),
136
+ loadmill: loadmillIcon(),
112
137
  };
113
138
  function actionIcon(event) {
114
139
  const lowerText = event.text.toLowerCase();
@@ -137,6 +162,9 @@ function actionIcon(event) {
137
162
  function renderAgentText(text) {
138
163
  return `<div class="dc-line dc-agent-line">${ICONS.botMessageSquare}<span>${escapeHtml(text)}</span></div>`;
139
164
  }
165
+ function textIcon(value) {
166
+ return `<span class="dc-icon" aria-hidden="true">${value}</span>`;
167
+ }
140
168
  // ---------- per-event timeline rendering (mirrors ExecutionLogItem.tsx) ----------
141
169
  function renderEvent(event, shots) {
142
170
  if (isHiddenExecutionLogEvent(event))
@@ -153,13 +181,13 @@ function renderEvent(event, shots) {
153
181
  : "Test Failed";
154
182
  const badge = summary.status === "passed" ? "PASS" : summary.status === "stopped" ? "STOPPED" : summary.status === "errored" ? "ERROR" : "FAIL";
155
183
  return `<div class="dc-card dc-${cls}">
156
- <div class="dc-card-head"><span class="dc-card-title">${escapeHtml(label)}</span><span class="dc-pill dc-pill-${cls}">${escapeHtml(badge)}</span></div>
184
+ <div class="dc-card-head"><span class="dc-card-title">${textIcon(summary.status === "passed" ? "✓" : "!")}${escapeHtml(label)}</span><span class="dc-pill dc-pill-${cls}">${escapeHtml(badge)}</span></div>
157
185
  <div class="dc-card-body">
158
186
  <div>Duration: ${escapeHtml(formatDuration(summary.durationMs))}</div>
159
187
  <div>Steps: ${summary.instructionsCompleted ?? 0}/${summary.instructionsTotal ?? 0}</div>
160
188
  <div>Assertions: ${summary.assertionsPassed ?? 0}/${(summary.assertionsPassed ?? 0) + (summary.assertionsFailed ?? 0)}</div>
161
189
  <div>Retries: ${summary.retries ?? 0}</div>
162
- </div>${summary.error ? `\n <div class="dc-card-error">${escapeHtml(summary.error)}</div>` : ""}
190
+ </div>${summary.error ? `\n <div class="dc-card-error">${renderLinkedText(summary.error)}</div>` : ""}
163
191
  </div>`;
164
192
  }
165
193
  const isInstruction = event.eventType === "instruction_started" || event.kind === "user";
@@ -186,7 +214,7 @@ function renderEvent(event, shots) {
186
214
  const cls = assertion.passed ? "pass" : "fail";
187
215
  const img = screenshotImg(event, shots);
188
216
  return `<div class="dc-card dc-${cls}">
189
- <div class="dc-card-head"><span class="dc-card-title">${assertion.passed ? "Assertion Passed" : "Assertion Failed"}${img}</span><span class="dc-pill dc-pill-${cls}">${assertion.passed ? "PASS" : "FAIL"}</span></div>
217
+ <div class="dc-card-head"><span class="dc-card-title">${textIcon(assertion.passed ? "✓" : "!")}${assertion.passed ? "Assertion Passed" : "Assertion Failed"}${img}</span><span class="dc-pill dc-pill-${cls}">${assertion.passed ? "PASS" : "FAIL"}</span></div>
190
218
  <div class="dc-assertion">${escapeHtml(assertion.assertion)}</div>${assertion.details ? `\n <div class="dc-assertion-details">${escapeHtml(assertion.details)}</div>` : ""}
191
219
  </div>`;
192
220
  }
@@ -194,18 +222,64 @@ function renderEvent(event, shots) {
194
222
  return `<div class="dc-line dc-retry">${ICONS.rotateCcw}<span>${escapeHtml(event.text)}</span></div>`;
195
223
  }
196
224
  if (event.kind === "action") {
197
- return `<div class="dc-line">${actionIcon(event)}<span>${escapeHtml(event.text)}</span>${screenshotImg(event, shots)}</div>`;
225
+ return `<div class="dc-line dc-action">${actionIcon(event)}<span>${escapeHtml(event.text)}</span>${screenshotImg(event, shots)}</div>`;
198
226
  }
199
227
  if (event.eventType === "screenshot_captured") {
200
228
  return `<div class="dc-line">${ICONS.camera}<span>${escapeHtml(event.text)}</span>${screenshotImg(event, shots)}</div>`;
201
229
  }
202
- return `<div class="dc-line dc-${escapeHtml(event.kind)}">${escapeHtml(event.text)}</div>`;
230
+ if (event.eventType === "design_generated_script" || (event.eventType === "tool_call" && event.payload?.toolName === "test_design_complete")) {
231
+ return `<div class="dc-line">${textIcon("✦")}<span>${escapeHtml(event.text)}</span></div>`;
232
+ }
233
+ return `<div class="dc-line dc-${escapeHtml(event.kind)}">${renderLinkedText(event.text)}</div>`;
203
234
  }
204
- function renderTimeline(events, shots) {
205
- return buildVisibleEvents(events)
206
- .map((event) => renderEvent(event, shots))
235
+ function renderAppContextCard(event) {
236
+ const text = typeof event.payload?.text === "string" ? event.payload.text.trim() : "";
237
+ if (!text)
238
+ return "";
239
+ return `<details class="dc-special dc-context">
240
+ <summary><span class="dc-special-title">${ICONS.bookOpen}App Context</span></summary>
241
+ <pre>${escapeHtml(text)}</pre>
242
+ </details>`;
243
+ }
244
+ function renderCloudCard(events) {
245
+ const label = cloudProviderLabel(events.map(cloudProviderId).find(Boolean));
246
+ const status = cloudStatus(events);
247
+ const lines = events
248
+ .filter((event) => event.payload?.event !== "cloud.app.check" && event.payload?.event !== "cloud.app.reupload_needed")
249
+ .map((event) => capitalizeReportLine(event.text))
250
+ .filter((text, index, all) => text !== all[index - 1]);
251
+ return `<details class="dc-special dc-cloud"${status === "issue" || status === "starting" ? " open" : ""}>
252
+ <summary><span class="dc-special-title">${ICONS.cloud}${escapeHtml(label)}</span><span class="dc-pill dc-pill-${status}">${status}</span></summary>
253
+ <div class="dc-special-body">${lines.map((line) => `<div>${renderLinkedText(line)}</div>`).join("")}</div>
254
+ </details>`;
255
+ }
256
+ function renderLoadmillCard(events) {
257
+ const lines = events.map((event) => event.text.replace(/^\[Loadmill\]\s*/, "").trim());
258
+ const failed = events.some((event) => /\[Loadmill\]\s*FAILED|^Error:/i.test(event.text));
259
+ const passed = !failed && events.some((event) => /\bpassed\b/i.test(event.text));
260
+ const status = failed ? "failed" : passed ? "passed" : "running";
261
+ return `<details class="dc-special dc-loadmill"${passed ? "" : " open"}>
262
+ <summary><span class="dc-special-title">${ICONS.loadmill}Loadmill Flow</span><span class="dc-pill dc-pill-${status}">${status}</span></summary>
263
+ <div class="dc-special-body">${lines.map((line) => `<div>${renderLinkedText(line)}</div>`).join("")}</div>
264
+ </details>`;
265
+ }
266
+ function renderTimeline(events, shots, warnings = []) {
267
+ const html = buildRunReportSections(buildVisibleEvents(events))
268
+ .map((item) => {
269
+ if (item.kind === "app-context")
270
+ return renderAppContextCard(item.line);
271
+ if (item.kind === "cloud")
272
+ return renderCloudCard(item.lines);
273
+ if (item.kind === "loadmill")
274
+ return renderLoadmillCard(item.lines);
275
+ return renderEvent(item.line, shots);
276
+ })
207
277
  .filter(Boolean)
208
278
  .join("\n");
279
+ if (html)
280
+ return html;
281
+ const message = warnings.find((warning) => warning.toLowerCase().includes("event log not available")) ?? "No event timeline available for this report.";
282
+ return `<div class="dc-empty">${escapeHtml(message)}</div>`;
209
283
  }
210
284
  // Full-size overlays, one per screenshot. Shown via the CSS `:target` rule, so
211
285
  // click-to-enlarge and prev/next/close work with JavaScript disabled.
@@ -290,7 +364,7 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
290
364
  .dc-test-row { display: grid; grid-template-columns: 1fr 90px 90px; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 6px; }
291
365
  .dc-test-name { font-weight: 500; color: #334155; }
292
366
  .dc-test-dur { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #64748b; font-size: 12px; }
293
- .dc-timeline { background: #fff; border: 1px solid #cbd5e1; border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
367
+ .dc-timeline { background: #fff; border: 1px solid #cbd5e1; border-radius: 12px; padding: 20px 24px; display: flex; flex-direction: column; gap: 8px; }
294
368
  .dc-child-block { margin-top: 12px; }
295
369
  .dc-child-head { font-size: 13px; font-weight: 600; color: #334155; margin: 8px 0; }
296
370
  .dc-row { display: flex; }
@@ -300,6 +374,9 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
300
374
  .dc-agent-line { align-items: flex-start; }
301
375
  .dc-agent-line .dc-icon { margin-top: 4px; }
302
376
  .dc-icon { width: 14px; height: 14px; flex: 0 0 auto; color: #94a3b8; }
377
+ .dc-line a, .dc-card-error a, .dc-special-body a { color: inherit; font-weight: 500; text-underline-offset: 2px; }
378
+ .dc-action { position: relative; min-height: 24px; }
379
+ .dc-action + .dc-action::before { content: ""; position: absolute; left: 7px; top: -9px; height: 10px; border-left: 1px solid #cbd5e1; }
303
380
  .dc-retry { color: #b45309; }
304
381
  .dc-error { color: #b91c1c; }
305
382
  .dc-warning { color: #b45309; }
@@ -318,7 +395,7 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
318
395
  .dc-lightbox-next { right: 10px; }
319
396
  .dc-card { border: 1px solid; border-radius: 12px; padding: 12px 14px; }
320
397
  .dc-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
321
- .dc-card-title { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; }
398
+ .dc-card-title { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; }
322
399
  .dc-card-body > div { margin: 1px 0; }
323
400
  .dc-card-error, .dc-assertion-details { margin-top: 6px; font-size: 13px; }
324
401
  .dc-assertion { font-size: 14px; }
@@ -331,6 +408,23 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
331
408
  .dc-pill-error, .dc-pill-errored, .dc-pill-amber { border-color: #fcd34d; background: #fef3c7; color: #b45309; }
332
409
  .dc-pill-stopped { border-color: #fdba74; background: #ffedd5; color: #c2410c; }
333
410
  .dc-pill-skipped { border-color: #cbd5e1; background: #f1f5f9; color: #334155; }
411
+ .dc-pill-ready, .dc-pill-passed { border-color: #6ee7b7; background: #d1fae5; color: #047857; }
412
+ .dc-pill-issue, .dc-pill-failed { border-color: #fda4af; background: #ffe4e6; color: #be123c; }
413
+ .dc-pill-ended { border-color: #cbd5e1; background: #f1f5f9; color: #334155; }
414
+ .dc-pill-starting { border-color: #a5b4fc; background: #e0e7ff; color: #4338ca; }
415
+ .dc-pill-running { border-color: #7dd3fc; background: #e0f2fe; color: #0369a1; }
416
+ .dc-special { width: 100%; border: 1px solid; border-radius: 12px; padding: 11px 14px; }
417
+ .dc-special summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; list-style: none; }
418
+ .dc-special summary::-webkit-details-marker { display: none; }
419
+ .dc-special summary::before { content: "›"; margin-right: 4px; color: currentColor; font-size: 18px; line-height: 1; transform-origin: center; }
420
+ .dc-special[open] summary::before { transform: rotate(90deg); }
421
+ .dc-special-title { display: inline-flex; flex: 1; align-items: center; gap: 5px; font-size: 13px; font-weight: 600; }
422
+ .dc-special-body { margin-top: 9px; display: flex; flex-direction: column; gap: 5px; color: #334155; }
423
+ .dc-context { border-color: #ddd6fe; background: #f5f3ff; color: #4c1d95; }
424
+ .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; }
425
+ .dc-cloud { border-color: #c7d2fe; background: #eef2ff; color: #312e81; }
426
+ .dc-loadmill { border-color: #bae6fd; background: #f0f9ff; color: #075985; }
427
+ .dc-empty { color: #64748b; }
334
428
  @keyframes dc-spin { to { transform: rotate(360deg); } }
335
429
  `.trim();
336
430
  // ---------- entry point ----------
@@ -343,7 +437,7 @@ export function renderHtmlReport(model, { title, includeScripts = true } = {}) {
343
437
  let body;
344
438
  if (model.childTests && model.childTests.length > 0) {
345
439
  const children = model.childTests
346
- .map((child) => `<div class="dc-child-block"><div class="dc-child-head">${escapeHtml(child.testName.replace(/\.dcua$/i, ""))}</div>${renderTimeline(child.events, shots)}</div>`)
440
+ .map((child) => `<div class="dc-child-block"><div class="dc-child-head">${escapeHtml(child.testName.replace(/\.dcua$/i, ""))}</div>${renderTimeline(child.events, shots, child.warnings)}</div>`)
347
441
  .join("\n");
348
442
  body = `${renderSummaryCard(model)}
349
443
  ${renderTestsList(model.childTests)}
@@ -354,7 +448,7 @@ ${children}
354
448
  else {
355
449
  body = `${renderSummaryCard(model)}
356
450
  <div class="dc-timeline">
357
- ${renderTimeline(model.events, shots)}
451
+ ${renderTimeline(model.events, shots, model.warnings)}
358
452
  </div>`;
359
453
  }
360
454
  const lightboxes = renderLightboxes(shots);
@@ -0,0 +1,136 @@
1
+ const CLOUD_PROVIDERS = [
2
+ ["browserstack", "BrowserStack", "BrowserStack"],
3
+ ["lambdatest", "LambdaTest", "LambdaTest"],
4
+ ["aws-device-farm", "(?:AWS )?Device Farm", "AWS"],
5
+ ["loadmill-cloud", "Loadmill Cloud", "Loadmill Cloud"],
6
+ ];
7
+ export function cloudProviderId(line) {
8
+ const payloadProviderId = line.payload?.providerId;
9
+ const fromPayload = CLOUD_PROVIDERS.find(([id]) => id === payloadProviderId);
10
+ if (fromPayload)
11
+ return fromPayload[0];
12
+ for (const [id, name] of CLOUD_PROVIDERS) {
13
+ if (new RegExp(`^(?:Preparing|Provisioning|Creating|Connecting to|Connected to|Ending|Checking cached|Reused cached|Cached|Reusing uploaded app .* for|Uploading .* to|Local app changed since the last) ${name}\\b`, "i").test(line.text) ||
14
+ new RegExp(`^${name} session (?:started|ended)`, "i").test(line.text)) {
15
+ return id;
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+ export function cloudProviderLabel(providerId) {
21
+ return CLOUD_PROVIDERS.find(([id]) => id === providerId)?.[2] ?? "Cloud session";
22
+ }
23
+ export function capitalizeReportLine(text) {
24
+ return text.replace(/^(\s*)([a-z])/, (_, whitespace, letter) => whitespace + letter.toUpperCase());
25
+ }
26
+ export function isCloudSessionLine(line) {
27
+ if (cloudProviderId(line))
28
+ return true;
29
+ return /^Upload complete for /i.test(line.text);
30
+ }
31
+ export function isCloudSessionContinuationLine(line) {
32
+ return !cloudProviderId(line) && /^Platform:\s/i.test(line.text);
33
+ }
34
+ export function cloudStatus(lines) {
35
+ if (lines.some((line) => line.kind === "error" || line.kind === "warning"))
36
+ return "issue";
37
+ if (lines.some((line) => line.payload?.event === "cloud.session.ended" || line.payload?.event === "cloud.session.ending"))
38
+ return "ended";
39
+ if (lines.some((line) => line.payload?.event === "cloud.session.connected"))
40
+ return "ready";
41
+ return "starting";
42
+ }
43
+ function isAppContextLine(line) {
44
+ return line.payload?.event === "app_context.loaded";
45
+ }
46
+ function isLoadmillLine(line) {
47
+ return line.text.startsWith("[Loadmill]");
48
+ }
49
+ function isLoadmillContinuationLine(line) {
50
+ return line.text.startsWith(" ") ||
51
+ line.text.startsWith("Command:") ||
52
+ line.text.startsWith("Error:") ||
53
+ line.text.startsWith("Skipping failed Loadmill");
54
+ }
55
+ function loadmillSuiteRunId(line) {
56
+ const payloadRunId = line.payload?.loadmillSuiteRunId;
57
+ if (typeof payloadRunId === "string" && payloadRunId.trim())
58
+ return payloadRunId.trim();
59
+ return line.text.match(/Test started \(ID:\s*([^)]+)\)/i)?.[1]?.trim() ||
60
+ line.text.match(/Run ID:\s*(.+)$/i)?.[1]?.trim() || null;
61
+ }
62
+ function loadmillCommand(line) {
63
+ const payloadCommand = line.payload?.loadmillCommand;
64
+ if (typeof payloadCommand === "string" && payloadCommand.trim())
65
+ return payloadCommand.trim();
66
+ return line.text.match(/^\[Loadmill\]\s*Executing:\s*(.+)$/i)?.[1]?.trim() || null;
67
+ }
68
+ function shouldAppendToPreviousLoadmillBlock(line, previousLines) {
69
+ if (previousLines.some((previous) => /\[Loadmill\]\s*FAILED|^Error:|\bpassed\b/i.test(previous.text)))
70
+ return false;
71
+ const runId = loadmillSuiteRunId(line);
72
+ if (runId)
73
+ return previousLines.some((previous) => loadmillSuiteRunId(previous) === runId);
74
+ const command = loadmillCommand(line);
75
+ return Boolean(command && previousLines.some((previous) => loadmillCommand(previous) === command));
76
+ }
77
+ function findPreviousSection(sections, kind) {
78
+ for (let index = sections.length - 1; index >= 0; index -= 1) {
79
+ if (sections[index].kind === kind)
80
+ return sections[index];
81
+ }
82
+ return null;
83
+ }
84
+ function findStartingCloudSection(sections, providerId) {
85
+ if (!providerId)
86
+ return null;
87
+ const previous = findPreviousSection(sections, "cloud");
88
+ if (!previous || cloudStatus(previous.lines) !== "starting")
89
+ return null;
90
+ return previous.lines.some((line) => cloudProviderId(line) === providerId) ? previous : null;
91
+ }
92
+ export function buildRunReportSections(logs) {
93
+ const sections = [];
94
+ let index = 0;
95
+ while (index < logs.length) {
96
+ const current = logs[index];
97
+ if (isAppContextLine(current)) {
98
+ sections.push({ kind: "app-context", line: current });
99
+ index += 1;
100
+ continue;
101
+ }
102
+ if (isCloudSessionLine(current)) {
103
+ const lines = [current];
104
+ index += 1;
105
+ while (index < logs.length && (isCloudSessionLine(logs[index]) || isCloudSessionContinuationLine(logs[index]))) {
106
+ lines.push(logs[index++]);
107
+ }
108
+ const startingSection = findStartingCloudSection(sections, lines.map(cloudProviderId).find(Boolean) ?? null);
109
+ if (startingSection && cloudStatus(lines) === "ended") {
110
+ startingSection.lines.push(...lines);
111
+ }
112
+ else {
113
+ sections.push({ kind: "cloud", lines });
114
+ }
115
+ continue;
116
+ }
117
+ if (!isLoadmillLine(current)) {
118
+ sections.push({ kind: "log", line: current, visibleIndex: index });
119
+ index += 1;
120
+ continue;
121
+ }
122
+ const previousLoadmill = findPreviousSection(sections, "loadmill");
123
+ if (previousLoadmill && shouldAppendToPreviousLoadmillBlock(current, previousLoadmill.lines)) {
124
+ previousLoadmill.lines.push(current);
125
+ index += 1;
126
+ continue;
127
+ }
128
+ const lines = [current];
129
+ index += 1;
130
+ while (index < logs.length && (isLoadmillLine(logs[index]) || isLoadmillContinuationLine(logs[index]))) {
131
+ lines.push(logs[index++]);
132
+ }
133
+ sections.push({ kind: "loadmill", lines });
134
+ }
135
+ return sections;
136
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.28.0",
3
+ "version": "2.29.0",
4
4
  "description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
5
5
  "main": "build/index.js",
6
6
  "type": "module",