@loadmill/droid-cua 2.28.0 → 2.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +5 -5
  2. package/build/index.js +142 -10
  3. package/build/src/browser/cloud-browser-catalog.js +27 -0
  4. package/build/src/browser/factory.js +33 -10
  5. package/build/src/browser/gesture-dispatch.js +281 -0
  6. package/build/src/browser/interface.js +3 -0
  7. package/build/src/browser/normalize.js +3 -1
  8. package/build/src/browser/playwright/actions.js +5 -271
  9. package/build/src/browser/webdriver/actions.js +10 -0
  10. package/build/src/browser/webdriver/client.js +83 -0
  11. package/build/src/browser/webdriver/connection.js +300 -0
  12. package/build/src/browser/webdriver/page-facade.js +123 -0
  13. package/build/src/browser/webdriver/session-state.js +35 -0
  14. package/build/src/cli/headless-execution-config.js +1 -1
  15. package/build/src/cli/headless-runner.js +26 -13
  16. package/build/src/cli/headless-target.js +87 -16
  17. package/build/src/cli/headless-test-source-label.js +4 -0
  18. package/build/src/cli/loadmill-cloud-device-connect-summary.js +6 -0
  19. package/build/src/cli/loadmill-cloud-session.js +26 -0
  20. package/build/src/core/context-optimization.js +1 -1
  21. package/build/src/core/execution-engine.js +9 -0
  22. package/build/src/core/report/render-html-report.js +106 -12
  23. package/build/src/core/report/run-report-sections.js +136 -0
  24. package/build/src/core/runtime-config.js +2 -2
  25. package/build/src/desktop-shared/connection-predicates.js +52 -0
  26. package/build/src/device/cloud/loadmill-cloud/adapter.js +17 -12
  27. package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +13 -7
  28. package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +22 -5
  29. package/build/src/device/cloud/loadmill-cloud/errors.js +25 -0
  30. package/build/src/device/cloud/loadmill-cloud/run-pool.js +41 -0
  31. package/build/src/device/cloud/loadmill-cloud/run-status.js +4 -0
  32. package/package.json +1 -1
  33. package/skill/droid-cua/SKILL.md +10 -3
  34. package/skill/droid-cua/agents/openai.yaml +2 -2
  35. package/skill/droid-cua/references/authoring-tests.md +36 -6
  36. package/skill/droid-cua/references/iterative-improvement.md +143 -0
  37. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +19 -0
  38. package/skill/droid-cua/references/project-and-auth.md +2 -0
  39. package/skill/droid-cua/references/running-and-healing.md +32 -14
@@ -1,3 +1,4 @@
1
+ import { CLOUD_BROWSER_FAMILIES, CLOUD_BROWSER_VERSION_ALIASES, LOCAL_BROWSER_FAMILIES, } from "../browser/cloud-browser-catalog.js";
1
2
  function normalizeTarget(value) {
2
3
  if (value == null || value === "") {
3
4
  return "mobile";
@@ -24,18 +25,31 @@ function normalizeSessionMode(value) {
24
25
  }
25
26
  throw new Error("--session-mode must be one of: persistent, fresh.");
26
27
  }
27
- function normalizeBrowser(value) {
28
+ function normalizeBrowser(value, allowedFamilies) {
28
29
  if (value == null || value === "") {
29
30
  return "chrome";
30
31
  }
31
32
  if (typeof value !== "string") {
32
- throw new Error("--browser must be one of: chrome, edge.");
33
+ throw new Error(`--browser must be one of: ${allowedFamilies.join(", ")}.`);
33
34
  }
34
35
  const normalized = value.trim().toLowerCase();
35
- if (normalized === "chrome" || normalized === "edge") {
36
+ if (allowedFamilies.includes(normalized)) {
36
37
  return normalized;
37
38
  }
38
- throw new Error("--browser must be one of: chrome, edge.");
39
+ throw new Error(`--browser must be one of: ${allowedFamilies.join(", ")}.`);
40
+ }
41
+ function normalizeCloudBrowserVersion(value) {
42
+ if (value == null || value === "") {
43
+ return "latest";
44
+ }
45
+ if (typeof value !== "string") {
46
+ throw new Error(`--browser-version must be one of: ${CLOUD_BROWSER_VERSION_ALIASES.join(", ")}.`);
47
+ }
48
+ const normalized = value.trim().toLowerCase();
49
+ if (CLOUD_BROWSER_VERSION_ALIASES.includes(normalized)) {
50
+ return normalized;
51
+ }
52
+ throw new Error(`--browser-version must be one of: ${CLOUD_BROWSER_VERSION_ALIASES.join(", ")}.`);
39
53
  }
40
54
  const CLOUD_SOURCE_DISPLAY_NAMES = {
41
55
  lambdatest: "LambdaTest",
@@ -80,25 +94,48 @@ function readNonEmptyCliValue(value) {
80
94
  }
81
95
  return null;
82
96
  }
83
- const ARTIFACT_KINDS = ["video", "deviceLog", "appiumLog"];
84
- function normalizeArtifactKinds(value) {
97
+ export const MOBILE_ARTIFACT_KINDS = ["video", "deviceLog", "appiumLog"];
98
+ export const WEB_ARTIFACT_KINDS = ["video", "seleniumLog"];
99
+ const UNRESOLVED_POOL_ARTIFACT_KINDS = [...MOBILE_ARTIFACT_KINDS, "seleniumLog"];
100
+ const ARTIFACT_KINDS_BY_POOL = {
101
+ mobile: MOBILE_ARTIFACT_KINDS,
102
+ web: WEB_ARTIFACT_KINDS,
103
+ };
104
+ export function artifactKindsForPool(pool) {
105
+ const kinds = ARTIFACT_KINDS_BY_POOL[pool];
106
+ if (!kinds) {
107
+ throw new Error(`Unknown Loadmill Cloud pool: ${pool}. Expected "mobile" or "web".`);
108
+ }
109
+ return [...kinds];
110
+ }
111
+ export function scopeArtifactKindsToPool(kinds, pool) {
112
+ const allowed = artifactKindsForPool(pool);
113
+ return kinds.filter((kind) => allowed.includes(kind));
114
+ }
115
+ function allowedArtifactKindsForTarget(target, poolKnown) {
116
+ if (!poolKnown) {
117
+ return UNRESOLVED_POOL_ARTIFACT_KINDS;
118
+ }
119
+ return target === "web" ? WEB_ARTIFACT_KINDS : MOBILE_ARTIFACT_KINDS;
120
+ }
121
+ function normalizeArtifactKinds(value, allowedKinds) {
85
122
  if (value == null || value === "") {
86
123
  return null;
87
124
  }
88
125
  if (typeof value !== "string") {
89
- throw new Error(`--artifacts must be a comma-separated list of: ${ARTIFACT_KINDS.join(", ")}, or all.`);
126
+ throw new Error(`--artifacts must be a comma-separated list of: ${allowedKinds.join(", ")}, or all.`);
90
127
  }
91
128
  const raw = value.trim();
92
129
  if (!raw) {
93
130
  return null;
94
131
  }
95
132
  if (raw.toLowerCase() === "all") {
96
- return [...ARTIFACT_KINDS];
133
+ return [...allowedKinds];
97
134
  }
98
135
  const kinds = [...new Set(raw.split(",").map((kind) => kind.trim()).filter(Boolean))];
99
- const invalid = kinds.filter((kind) => !ARTIFACT_KINDS.includes(kind));
136
+ const invalid = kinds.filter((kind) => !allowedKinds.includes(kind));
100
137
  if (invalid.length > 0) {
101
- throw new Error(`--artifacts has invalid kind(s): ${invalid.join(", ")}. Must be one of: ${ARTIFACT_KINDS.join(", ")}, or all.`);
138
+ throw new Error(`--artifacts has invalid kind(s): ${invalid.join(", ")}. Must be one of: ${allowedKinds.join(", ")}, or all.`);
102
139
  }
103
140
  return kinds;
104
141
  }
@@ -144,9 +181,10 @@ function normalizeCloudAppPath(value, platform, deviceSource) {
144
181
  export function shouldWaitForLoadmillCloudCompletion(targetConfig) {
145
182
  return Array.isArray(targetConfig?.artifacts) && targetConfig.artifacts.length > 0;
146
183
  }
184
+ const MOBILE_ONLY_CLOUD_SOURCES = new Set(["lambdatest", "aws-device-farm"]);
147
185
  export function resolveHeadlessTarget(args = {}) {
148
186
  const target = normalizeTarget(args.target);
149
- const sessionMode = normalizeSessionMode(args["session-mode"]);
187
+ const targetGiven = hasNonEmptyString(args.target);
150
188
  const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
151
189
  const platform = normalizeMobilePlatform(args.platform, "--platform");
152
190
  // The mtc_ prefix on --cloud-session-id is loadmill-cloud's; when no
@@ -164,14 +202,44 @@ export function resolveHeadlessTarget(args = {}) {
164
202
  if (avdName || platform) {
165
203
  throw new Error("--avd and --platform cannot be used with --target web.");
166
204
  }
167
- if (deviceSource !== "local") {
168
- throw new Error("--device-source can only be used with --target mobile.");
205
+ if (MOBILE_ONLY_CLOUD_SOURCES.has(deviceSource)) {
206
+ throw new Error(`--device-source ${deviceSource} can only be used with --target mobile.`);
207
+ }
208
+ if (hasNonEmptyString(args["device-name"])) {
209
+ throw new Error("--device-name cannot be used with --target web.");
210
+ }
211
+ if (readNonEmptyCliValue(args["os-version"]) != null) {
212
+ throw new Error("--os-version cannot be used with --target web.");
213
+ }
214
+ if (hasNonEmptyString(args.app)) {
215
+ throw new Error("--app cannot be used with --target web.");
216
+ }
217
+ if (deviceSource === "loadmill-cloud") {
218
+ if (args["session-mode"] != null) {
219
+ throw new Error("--session-mode can only be used with --device-source local.");
220
+ }
221
+ return {
222
+ target,
223
+ sessionMode: null,
224
+ browserFamily: normalizeBrowser(args.browser, CLOUD_BROWSER_FAMILIES),
225
+ browserVersion: normalizeCloudBrowserVersion(args["browser-version"]),
226
+ deviceSource: "loadmill-cloud",
227
+ avdName: null,
228
+ platform: null,
229
+ deviceName: null,
230
+ osVersion: null,
231
+ appPath: null,
232
+ cloudSessionId: readNonEmptyCliValue(args["cloud-session-id"]),
233
+ artifacts: normalizeArtifactKinds(args.artifacts, WEB_ARTIFACT_KINDS),
234
+ listDevices: false,
235
+ };
169
236
  }
170
237
  assertNoCloudFlags(args);
171
238
  return {
172
239
  target,
173
- sessionMode,
174
- browserFamily: normalizeBrowser(args.browser),
240
+ sessionMode: normalizeSessionMode(args["session-mode"]),
241
+ browserFamily: normalizeBrowser(args.browser, LOCAL_BROWSER_FAMILIES),
242
+ browserVersion: null,
175
243
  deviceSource: "local",
176
244
  avdName: null,
177
245
  platform: null,
@@ -195,6 +263,7 @@ export function resolveHeadlessTarget(args = {}) {
195
263
  target,
196
264
  sessionMode: null,
197
265
  browserFamily: null,
266
+ browserVersion: null,
198
267
  deviceSource,
199
268
  avdName: null,
200
269
  platform: null,
@@ -215,7 +284,7 @@ export function resolveHeadlessTarget(args = {}) {
215
284
  const cloudSessionId = readNonEmptyCliValue(args["cloud-session-id"]);
216
285
  const isReconnect = deviceSource === "loadmill-cloud" && Boolean(cloudSessionId);
217
286
  const appPath = normalizeCloudAppPath(args.app, platform, deviceSource);
218
- const artifacts = normalizeArtifactKinds(args.artifacts);
287
+ const artifacts = normalizeArtifactKinds(args.artifacts, allowedArtifactKindsForTarget(target, targetGiven || !isReconnect));
219
288
  if (artifacts && deviceSource !== "loadmill-cloud") {
220
289
  throw new Error("--artifacts can only be used with --device-source loadmill-cloud.");
221
290
  }
@@ -241,6 +310,7 @@ export function resolveHeadlessTarget(args = {}) {
241
310
  target,
242
311
  sessionMode: null,
243
312
  browserFamily: null,
313
+ browserVersion: null,
244
314
  deviceSource,
245
315
  avdName: null,
246
316
  platform,
@@ -256,6 +326,7 @@ export function resolveHeadlessTarget(args = {}) {
256
326
  target,
257
327
  sessionMode: null,
258
328
  browserFamily: null,
329
+ browserVersion: null,
259
330
  deviceSource,
260
331
  avdName,
261
332
  platform,
@@ -0,0 +1,4 @@
1
+ const INLINE_SOURCE_PATTERN = /^\/dev\/(fd\/\d+|stdin)$/;
2
+ export function formatHeadlessTestSourceLabel(sourcePath) {
3
+ return INLINE_SOURCE_PATTERN.test(sourcePath) ? "(inline instructions)" : sourcePath;
4
+ }
@@ -0,0 +1,6 @@
1
+ export function buildLoadmillCloudDeviceConnectSummaryLines({ deviceInfo }) {
2
+ return [
3
+ `Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`,
4
+ `Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`,
5
+ ];
6
+ }
@@ -0,0 +1,26 @@
1
+ export async function withLoadmillCloudSession({ credentials, connect, teardown, run = null }) {
2
+ const targetId = await connect(credentials);
3
+ if (typeof run !== "function") {
4
+ return targetId;
5
+ }
6
+ try {
7
+ return await run(targetId);
8
+ }
9
+ finally {
10
+ await teardown();
11
+ }
12
+ }
13
+ export function createBrowserSessionTeardown({ disconnect, releaseSession }) {
14
+ return async () => {
15
+ try {
16
+ await disconnect();
17
+ }
18
+ catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ console.error(`[loadmill-cloud] browser disconnect failed: ${message}`);
21
+ }
22
+ finally {
23
+ await releaseSession();
24
+ }
25
+ };
26
+ }
@@ -1,5 +1,5 @@
1
1
  export const DEFAULT_CONTEXT_OPTIMIZATION_ENABLED = true;
2
- export const DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD = 30000;
2
+ export const DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD = 60000;
3
3
  export const MIN_CONTEXT_OPTIMIZATION_THRESHOLD = 10000;
4
4
  export const MAX_CONTEXT_OPTIMIZATION_THRESHOLD = 900000;
5
5
  export const CONTEXT_OPTIMIZATION_ENABLED_ENV = "DROID_CUA_CONTEXT_OPTIMIZATION_ENABLED";
@@ -503,6 +503,15 @@ export class ExecutionEngine {
503
503
  }
504
504
  emitResponseOutputItems(items, addOutput, eventMeta, options = {}) {
505
505
  for (const item of items) {
506
+ if (item.type === "compaction") {
507
+ addOutput({
508
+ type: "info",
509
+ text: "Optimized context",
510
+ eventType: "system_message",
511
+ ...eventMeta({ payload: { event: "context.optimized" } }),
512
+ });
513
+ continue;
514
+ }
506
515
  if (item.type === "reasoning") {
507
516
  for (const entry of item.summary) {
508
517
  if (entry.type !== "summary_text")
@@ -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, """)
13
15
  .replace(/'/g, "'");
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
+ }
@@ -123,7 +123,7 @@ export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToke
123
123
  loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
124
124
  };
125
125
  }
126
- export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, explainMode = false, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
126
+ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, explainMode = true, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
127
127
  const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
128
128
  ? cuaModel
129
129
  : resolveCuaModel(cuaModel);
@@ -133,7 +133,7 @@ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null,
133
133
  ? "loadmill"
134
134
  : normalizedProviderMode,
135
135
  cuaModel: normalizedCuaModel,
136
- explainMode: explainMode === true,
136
+ explainMode: explainMode !== false,
137
137
  stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
138
138
  contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
139
139
  contextOptimizationThreshold: normalizeContextOptimizationThreshold(contextOptimizationThreshold),