@loadmill/droid-cua 2.44.0 → 2.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,8 @@
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, networkCardData, isNetworkCaptureStarted, 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";
5
+ import { assertionData, isRepeatedRunError, stepFailureData, isRepeatedStepFailureMessage, networkCardData, isNetworkCaptureNotice, runNotice, hasNearbyAssertionCard, isAssistantAssertionSummary, isDuplicateAssertionLine, isHiddenExecutionLogEvent, runSummaryData, runSummaryCounts, stepCompletionData, stepCompletionReasonTooltip, } from "./run-log-model.js";
6
+ import { buildRunReportSections, instructionActivities, 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) ----------
9
9
  function escapeHtml(value) {
@@ -36,14 +36,14 @@ function formatRunTimestamp(iso) {
36
36
  const parsed = Date.parse(iso);
37
37
  if (!Number.isFinite(parsed))
38
38
  return "—";
39
- return new Date(parsed).toLocaleString("en-US");
39
+ return new Date(parsed).toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" });
40
40
  }
41
41
  function formatRunDuration(durationSeconds) {
42
42
  if (durationSeconds === null || durationSeconds === undefined || !Number.isFinite(durationSeconds)) {
43
43
  return "—";
44
44
  }
45
45
  if (durationSeconds < 60)
46
- return `${durationSeconds.toFixed(1)}s`;
46
+ return `${Math.round(durationSeconds)}s`;
47
47
  const wholeSeconds = Math.floor(durationSeconds);
48
48
  const minutes = Math.floor(wholeSeconds / 60);
49
49
  const seconds = wholeSeconds % 60;
@@ -56,9 +56,9 @@ function formatRunDuration(durationSeconds) {
56
56
  function runStatusLabel(status) {
57
57
  switch (status) {
58
58
  case "pass":
59
- return "Pass";
59
+ return "Passed";
60
60
  case "fail":
61
- return "Fail";
61
+ return "Failed";
62
62
  case "error":
63
63
  return "Error";
64
64
  case "stopped":
@@ -80,11 +80,20 @@ function runStatusLabel(status) {
80
80
  // assertion lines (the same assertion is logged several times).
81
81
  function buildVisibleEvents(events) {
82
82
  const base = (events ?? []).filter((event) => typeof event.text === "string" && event.text.trim().length > 0);
83
+ const hasSummary = base.some((event) => runSummaryData(event));
83
84
  const filtered = base.filter((event, idx, items) => {
85
+ if (hasSummary && event.eventType !== "run_finished" && ["system", "info", "success"].includes(event.kind) && /^(Test completed|Duration:|Steps:|Assertions:|Retries:)/.test(event.text.trimStart()))
86
+ return false;
87
+ if (["system", "info"].includes(event.kind) && /^(Loading test:|Loaded \d+ instructions|Connecting to )/.test(event.text.trimStart()))
88
+ return false;
84
89
  if (isHiddenExecutionLogEvent(event))
85
90
  return false;
91
+ if (event.kind !== "user" && event.eventType !== "instruction_started" && event.text.startsWith("Running Loadmill flow: "))
92
+ return false;
86
93
  if (isAssistantAssertionSummary(event) && hasNearbyAssertionCard(items, idx))
87
94
  return false;
95
+ if (isRepeatedStepFailureMessage(items, idx))
96
+ return false;
88
97
  if (event.eventType === "input_request")
89
98
  return false;
90
99
  return true;
@@ -108,7 +117,7 @@ function screenshotImg(event, shots) {
108
117
  return "";
109
118
  const index = shots.length + 1;
110
119
  shots.push({ index, url, event });
111
- return `<a class="dc-shot-link" href="#dc-shot-${index}" aria-label="Open screenshot ${index}"><img class="dc-shot" src="${escapeHtml(url)}" alt="Screenshot ${index}" /></a>`;
120
+ return `<a class="dc-shot-link"${url.startsWith("data:") ? "" : ' data-remote-image'} href="#dc-shot-${index}" aria-label="Open screenshot ${index}"><img class="dc-shot" src="${escapeHtml(url)}" alt="Screenshot ${index}" /></a>`;
112
121
  }
113
122
  function icon(name, shapes) {
114
123
  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>`;
@@ -118,6 +127,18 @@ function loadmillIcon() {
118
127
  return `<svg class="dc-icon" data-icon="loadmill-mark" aria-hidden="true" viewBox="0 0 1035 1035">${paths}</svg>`;
119
128
  }
120
129
  const ICONS = {
130
+ assertion: icon("shield-check", `<path d="M12 22s8-4 8-11V5l-8-3-8 3v6c0 7 8 11 8 11Z"/><path d="m9 12 2 2 4-4"/>`),
131
+ triangleAlert: icon("triangle-alert", `<path d="m10.3 3.9-8.1 14a2 2 0 0 0 1.7 3h16.2a2 2 0 0 0 1.7-3l-8.1-14a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4m0 4h.01"/>`),
132
+ check: icon("check", `<path d="m5 12 4 4L19 6"/>`),
133
+ alert: icon("alert", `<circle cx="12" cy="12" r="9"/><path d="M12 8v4m0 4h.01"/>`),
134
+ clipboard: icon("clipboard", `<rect x="5" y="4" width="14" height="18" rx="2"/><rect x="9" y="2" width="6" height="4" rx="1"/><path d="m9 13 2 2 4-4"/>`),
135
+ calendar: icon("calendar", `<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 11h18"/>`),
136
+ clock: icon("clock", `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`),
137
+ folder: icon("folder", `<path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>`),
138
+ device: icon("device", `<rect x="2" y="3" width="15" height="12" rx="2"/><path d="M7 19h5m-3-4v4"/><rect x="17" y="11" width="5" height="10" rx="1"/>`),
139
+ chevron: icon("chevron", `<path d="m9 6 6 6-6 6"/>`),
140
+ info: icon("info", `<circle cx="12" cy="12" r="9"/><path d="M12 11v6m0-10h.01"/>`),
141
+ expand: icon("expand", `<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>`),
121
142
  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"/>`),
122
143
  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"/>`),
123
144
  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"/>`),
@@ -167,33 +188,19 @@ function textIcon(value) {
167
188
  return `<span class="dc-icon" aria-hidden="true">${value}</span>`;
168
189
  }
169
190
  // ---------- per-event timeline rendering (mirrors ExecutionLogItem.tsx) ----------
170
- function renderEvent(event, shots) {
191
+ function renderEvent(event, shots, events) {
171
192
  if (isHiddenExecutionLogEvent(event))
172
193
  return "";
194
+ if (event.eventType === "tool_call" && event.text.startsWith("Validating assertion: "))
195
+ return "";
196
+ const notice = runNotice(event);
197
+ if (notice)
198
+ return `<div role="status" class="dc-notice${notice.warning ? " dc-notice-warning" : ""}">${notice.warning ? ICONS.triangleAlert : ICONS.network}<div><p>${escapeHtml(notice.message)}</p>${notice.details ? `<details><summary>Error details</summary><pre>${escapeHtml(notice.details)}</pre></details>` : ""}</div></div>`;
199
+ if (isNetworkCaptureNotice(event))
200
+ return "";
173
201
  const summary = runSummaryData(event);
174
202
  if (summary) {
175
- const cls = summary.status === "passed" ? "pass" : summary.status === "failed" ? "fail" : "amber";
176
- const label = summary.status === "passed"
177
- ? "Test Passed"
178
- : summary.status === "stopped"
179
- ? "Test Stopped"
180
- : summary.status === "errored"
181
- ? "Error"
182
- : "Test Failed";
183
- const badge = summary.status === "passed" ? "PASS" : summary.status === "stopped" ? "STOPPED" : summary.status === "errored" ? "ERROR" : "FAIL";
184
- return `<div class="dc-card dc-${cls}">
185
- <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>
186
- <div class="dc-card-body">
187
- <div>Duration: ${escapeHtml(formatDuration(summary.durationMs))}</div>
188
- <div>Steps: ${summary.instructionsCompleted ?? 0}/${summary.instructionsTotal ?? 0}</div>
189
- <div>Assertions: ${summary.assertionsPassed ?? 0}/${(summary.assertionsPassed ?? 0) + (summary.assertionsFailed ?? 0)}</div>
190
- <div>Retries: ${summary.retries ?? 0}</div>
191
- </div>${summary.error ? `\n <div class="dc-card-error">${renderLinkedText(summary.error)}</div>` : ""}
192
- </div>`;
193
- }
194
- const isInstruction = event.eventType === "instruction_started" || event.kind === "user";
195
- if (isInstruction) {
196
- return `<div class="dc-row dc-row-right"><div class="dc-bubble">${escapeHtml(event.text)}</div></div>`;
203
+ return summary.status !== "stopped" && summary.error && !isRepeatedRunError(summary.error, events) ? `<div class="dc-line dc-error">${renderLinkedText(summary.error)}</div>` : "";
197
204
  }
198
205
  const isAssistant = event.eventType === "assistant_message" || event.kind === "assistant";
199
206
  if (isAssistant) {
@@ -210,13 +217,14 @@ function renderEvent(event, shots) {
210
217
  if (event.eventType === "reasoning" || event.kind === "reasoning") {
211
218
  return renderAgentText(event.text);
212
219
  }
213
- const assertion = assertionData(event);
220
+ const stepFailure = stepFailureData(event);
221
+ const assertion = assertionData(event) || stepFailure;
214
222
  if (assertion) {
215
223
  const cls = assertion.passed ? "pass" : "fail";
216
224
  const img = screenshotImg(event, shots);
217
- return `<div class="dc-card dc-${cls}">
218
- <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>
219
- <div class="dc-assertion">${escapeHtml(assertion.assertion)}</div>${assertion.details ? `\n <div class="dc-assertion-details">${escapeHtml(assertion.details)}</div>` : ""}
225
+ return `<div${stepFailure ? ' role="alert"' : ""} class="dc-card dc-${cls}">
226
+ <div class="dc-card-head"><span class="dc-card-title">${assertion.passed ? ICONS.check : ICONS.triangleAlert}${stepFailure?.title || (assertion.passed ? "Assertion Passed" : "Assertion Failed")}${img}</span></div>
227
+ <div class="dc-assertion-fields"><p><span class="dc-expected-label">Expected:</span> ${escapeHtml(assertion.assertion)}</p>${assertion.details ? renderAgentText(assertion.details) : ""}</div>
220
228
  </div>`;
221
229
  }
222
230
  if (event.eventType === "retry") {
@@ -225,9 +233,6 @@ function renderEvent(event, shots) {
225
233
  if (event.kind === "action") {
226
234
  return `<div class="dc-line dc-action">${actionIcon(event)}<span>${escapeHtml(event.text)}</span>${screenshotImg(event, shots)}</div>`;
227
235
  }
228
- if (isNetworkCaptureStarted(event)) {
229
- return `<div class="dc-line">${ICONS.network}<span>Network capture started</span></div>`;
230
- }
231
236
  if (event.eventType === "screenshot_captured") {
232
237
  return `<div class="dc-line">${ICONS.camera}<span>${escapeHtml(event.text)}</span>${screenshotImg(event, shots)}</div>`;
233
238
  }
@@ -245,14 +250,14 @@ function renderAppContextCard(event) {
245
250
  <pre>${escapeHtml(text)}</pre>
246
251
  </details>`;
247
252
  }
248
- function renderCloudCard(events) {
253
+ function renderCloudCard(events, nested = false) {
249
254
  const label = cloudProviderLabel(events.map(cloudProviderId).find(Boolean));
250
255
  const status = cloudStatus(events);
251
256
  const lines = events
252
257
  .filter((event) => event.payload?.event !== "cloud.app.check" && event.payload?.event !== "cloud.app.reupload_needed")
253
258
  .map((event) => capitalizeReportLine(event.text))
254
259
  .filter((text, index, all) => text !== all[index - 1]);
255
- return `<details class="dc-special dc-cloud"${status === "issue" || status === "starting" ? " open" : ""}>
260
+ return `<details class="dc-special dc-cloud"${nested || status === "issue" || status === "starting" ? " open" : ""}>
256
261
  <summary><span class="dc-special-title">${ICONS.cloud}${escapeHtml(label)}</span><span class="dc-pill dc-pill-${status}">${status}</span></summary>
257
262
  <div class="dc-special-body">${lines.map((line) => `<div>${renderLinkedText(line)}</div>`).join("")}</div>
258
263
  </details>`;
@@ -286,17 +291,17 @@ function renderLoadmillCard(events) {
286
291
  const failed = events.some((event) => /\[Loadmill\]\s*FAILED|^Error:/i.test(event.text));
287
292
  const passed = !failed && events.some((event) => /\bpassed\b/i.test(event.text));
288
293
  const status = failed ? "failed" : passed ? "passed" : "running";
289
- return `<details class="dc-special dc-loadmill"${passed ? "" : " open"}>
290
- <summary><span class="dc-special-title">${ICONS.loadmill}Loadmill Flow</span><span class="dc-pill dc-pill-${status}">${status}</span></summary>
294
+ return `<details class="dc-special dc-loadmill dc-loadmill-${status}" open>
295
+ <summary aria-label="Loadmill Flow: ${status}"><span class="dc-special-title">${ICONS.loadmill}Loadmill Flow</span></summary>
291
296
  <div class="dc-special-body">${renderedLines.join("")}</div>
292
297
  </details>`;
293
298
  }
294
- function renderNetworkCard(lines) {
299
+ function renderNetworkCard(lines, nested = false) {
295
300
  const { outcome, question, filters, answer } = networkCardData(lines);
296
301
  const resultHtml = answer.structured
297
302
  ? `<details class="dc-network-data"><summary>Response data</summary><pre>${escapeHtml(answer.text)}</pre></details>`
298
303
  : `<div class="dc-network-answer">${renderLinkedText(answer.text)}</div>`;
299
- return `<details class="dc-special dc-network">
304
+ return `<details class="dc-special dc-network"${nested ? " open" : ""}>
300
305
  <summary><span class="dc-special-title">${ICONS.network}Network query</span><span>${escapeHtml(outcome)}</span></summary>
301
306
  <div class="dc-special-body">
302
307
  <dl class="dc-network-filters">${filters.map(({ label, value }) => `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`).join("")}</dl>
@@ -306,20 +311,55 @@ function renderNetworkCard(lines) {
306
311
  </details>`;
307
312
  }
308
313
  function renderTimeline(events, shots, warnings = []) {
309
- const html = buildRunReportSections(buildVisibleEvents(events))
310
- .map((item) => {
314
+ const stopped = events.some((event) => runSummaryData(event)?.status === "stopped");
315
+ const visible = buildVisibleEvents(events).filter((event) => !stopped || event.eventType === "run_finished" || !/^(Stopping test execution|Stopped by user)/.test(event.text.trim()));
316
+ const renderItems = (items, nested) => buildRunReportSections(items).map((item) => {
311
317
  if (item.kind === "app-context")
312
318
  return renderAppContextCard(item.line);
313
319
  if (item.kind === "cloud")
314
- return renderCloudCard(item.lines);
320
+ return renderCloudCard(item.lines, nested);
315
321
  if (item.kind === "network")
316
- return renderNetworkCard(item.lines);
322
+ return renderNetworkCard(item.lines, nested);
317
323
  if (item.kind === "loadmill")
318
324
  return renderLoadmillCard(item.lines);
319
- return renderEvent(item.line, shots);
320
- })
321
- .filter(Boolean)
322
- .join("\n");
325
+ return renderEvent(item.line, shots, visible);
326
+ }).filter(Boolean).join("\n");
327
+ const groups = [];
328
+ let current = [];
329
+ let instruction = null;
330
+ const flush = () => { if (instruction || current.length)
331
+ groups.push({ instruction, items: current }); current = []; };
332
+ for (const event of visible) {
333
+ if (event.eventType === "instruction_started" || event.kind === "user" || event.eventType === "run_finished") {
334
+ flush();
335
+ instruction = event.eventType === "run_finished" ? null : event;
336
+ if (instruction)
337
+ continue;
338
+ }
339
+ current.push(event);
340
+ }
341
+ flush();
342
+ const result = visible.map(runSummaryData).find(Boolean);
343
+ const outcome = result?.status;
344
+ const passed = outcome === "passed";
345
+ const instructions = groups.map((group) => group.instruction).filter(Boolean);
346
+ const lastInstruction = instructions.at(-1);
347
+ const html = groups.map(({ instruction, items }) => {
348
+ const content = renderItems(items, Boolean(instruction));
349
+ if (!instruction)
350
+ return content;
351
+ const failed = items.some((event) => event.kind === "error" || assertionData(event)?.passed === false) || (instruction === lastInstruction && (outcome === "failed" || outcome === "errored"));
352
+ const confirmedByCount = (instruction.instructionIndex ?? instructions.indexOf(instruction)) < (result?.instructionsCompleted ?? 0);
353
+ const complete = !failed && (instruction !== lastInstruction || confirmedByCount || passed || items.some((event) => stepCompletionData(event)));
354
+ const interrupted = stopped && !complete && !failed && instruction === lastInstruction;
355
+ const status = failed ? "Needs attention" : complete ? "Completed" : interrupted ? "Stopped" : "Not confirmed";
356
+ const end = items.find((event) => stepCompletionData(event)) ?? instructions[instructions.indexOf(instruction) + 1] ?? visible.find((event) => event.eventType === "run_finished") ?? items[items.length - 1];
357
+ const seconds = (Date.parse(end?.ts) - Date.parse(instruction.ts)) / 1000;
358
+ const duration = Number.isFinite(seconds) && seconds >= 0 ? formatRunDuration(seconds) : "";
359
+ const activities = instructionActivities(items);
360
+ const activityIcons = activities.length ? `<span class="dc-instruction-activities">${activities.map(({ type, label }) => `<span class="dc-activity dc-activity-${type}" title="${label}" aria-label="${label}">${ICONS[type]}</span>`).join("")}</span>` : "";
361
+ return `<details class="dc-instruction"${(failed && !passed) || interrupted ? " open" : ""}><summary><span class="dc-instruction-status ${failed ? "dc-error" : complete ? "dc-complete" : interrupted ? "dc-stopped" : ""}" aria-label="${status}">${failed || interrupted ? ICONS.alert : complete ? ICONS.check : "○"}</span><span class="dc-instruction-label">${escapeHtml(instruction.text)}<span class="dc-instruction-duration">${interrupted ? "Stopped after " : complete ? "Completed in " : "Elapsed "}${escapeHtml(duration)}${activityIcons}<span class="dc-instruction-chevron">${ICONS.chevron}</span></span></span><time class="dc-instruction-time">${escapeHtml(Number.isFinite(Date.parse(instruction.ts)) ? new Date(instruction.ts).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) : "")}</time></summary><div class="dc-instruction-actions">${content || '<div class="dc-empty">No recorded actions</div>'}</div></details>`;
362
+ }).join("\n");
323
363
  if (html)
324
364
  return html;
325
365
  const message = warnings.find((warning) => warning.toLowerCase().includes("event log not available")) ?? "No event timeline available for this report.";
@@ -339,7 +379,7 @@ function renderLightboxes(shots) {
339
379
  const next = shot.index < count
340
380
  ? `<a class="dc-lightbox-nav dc-lightbox-next" href="#dc-shot-${shot.index + 1}" aria-label="Next screenshot">&#8250;</a>`
341
381
  : "";
342
- return `<div class="dc-lightbox" id="dc-shot-${shot.index}">
382
+ return `<div class="dc-lightbox" id="dc-shot-${shot.index}"${shot.url.startsWith("data:") ? "" : ' data-remote-image'}>
343
383
  <a class="dc-lightbox-backdrop" href="#dc-top" aria-label="Close"></a>
344
384
  ${prev}
345
385
  <img class="dc-lightbox-img" src="${escapeHtml(shot.url)}" alt="Screenshot ${shot.index}" />
@@ -349,21 +389,102 @@ function renderLightboxes(shots) {
349
389
  })
350
390
  .join("\n");
351
391
  }
352
- // Progressive enhancement: map arrow keys / Escape to the same hash navigation the
353
- // CSS overlays already use. Inline only — keeps the report a single self-contained file.
354
- function renderLightboxScript(count) {
355
- if (count === 0)
356
- return "";
392
+ // Enhance native details and screenshot links for downloads. Hosted reports omit
393
+ // this script and retain inline details and CSS lightboxes without JavaScript.
394
+ function renderInteractionScript(count) {
357
395
  return `<script>
358
396
  (function () {
397
+ document.querySelectorAll('.dc-run-details, .dc-context').forEach(function(details, index) {
398
+ var isContext = details.classList.contains('dc-context');
399
+ var title = isContext ? 'App context' : 'Run details';
400
+ var trigger = document.createElement('button');
401
+ trigger.type = 'button';
402
+ trigger.className = 'dc-dialog-trigger';
403
+ trigger.innerHTML = details.querySelector('summary').innerHTML;
404
+ trigger.setAttribute('aria-haspopup', 'dialog');
405
+ var dialog = document.createElement('dialog');
406
+ dialog.className = 'dc-dialog';
407
+ dialog.setAttribute('aria-labelledby', 'dc-dialog-title-' + index);
408
+ var header = document.createElement('div');
409
+ header.className = 'dc-dialog-header';
410
+ var heading = document.createElement('h2');
411
+ heading.id = 'dc-dialog-title-' + index;
412
+ heading.textContent = title;
413
+ var close = document.createElement('button');
414
+ close.type = 'button';
415
+ close.textContent = '×';
416
+ close.setAttribute('aria-label', 'Close ' + title.toLowerCase());
417
+ header.append(heading, close);
418
+ dialog.append(header);
419
+ Array.from(details.children).forEach(function(child) {
420
+ if (child.tagName !== 'SUMMARY') dialog.append(child);
421
+ });
422
+ var childReport = details.closest('.dc-child-block');
423
+ if (isContext) {
424
+ var destination = childReport ? childReport.querySelector('.dc-child-head') : document.querySelector('.dc-summary');
425
+ if (childReport) destination.append(trigger);
426
+ else {
427
+ trigger.style.marginLeft = 'auto';
428
+ var runDetails = destination.querySelector('.dc-run-details');
429
+ if (runDetails) runDetails.style.marginLeft = '0';
430
+ destination.insertBefore(trigger, runDetails);
431
+ }
432
+ details.remove();
433
+ } else {
434
+ trigger.classList.add('dc-run-details');
435
+ details.replaceWith(trigger);
436
+ }
437
+ document.body.append(dialog);
438
+ trigger.addEventListener('click', function() { dialog.showModal(); });
439
+ close.addEventListener('click', function() { dialog.close(); });
440
+ dialog.addEventListener('click', function(event) {
441
+ var rect = dialog.getBoundingClientRect();
442
+ if (event.target === dialog && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) dialog.close();
443
+ });
444
+ });
359
445
  var count = ${count};
446
+ var panelIndex = count;
447
+ var panel = document.getElementById('dc-panel-image');
448
+ function select(index) {
449
+ panelIndex = Math.max(1, Math.min(count, index));
450
+ var source = document.querySelector('#dc-shot-' + panelIndex + ' img');
451
+ panel.src = source.src;
452
+ document.getElementById('dc-panel-open').href = '#dc-shot-' + panelIndex;
453
+ document.getElementById('dc-panel-expand').href = '#dc-shot-' + panelIndex;
454
+ document.getElementById('dc-panel-position').value = panelIndex;
455
+ document.getElementById('dc-panel-count').textContent = panelIndex + ' / ' + count;
456
+ document.querySelectorAll('[data-shot]').forEach(function(button) {
457
+ button.disabled = ['first', 'previous'].includes(button.dataset.shot) ? panelIndex === 1 : panelIndex === count;
458
+ });
459
+ var thumb = document.querySelector('.dc-shot-link[href="#dc-shot-' + panelIndex + '"]');
460
+ var group = thumb && thumb.closest('.dc-instruction');
461
+ document.getElementById('dc-panel-caption').textContent = group ? group.querySelector('summary > span:nth-child(2)').textContent : 'Screenshot ' + panelIndex;
462
+ }
463
+ if (panel) {
464
+ document.querySelectorAll('.dc-shot-link').forEach(function(thumb) {
465
+ thumb.addEventListener('click', function(event) {
466
+ event.preventDefault();
467
+ select(Number(thumb.getAttribute('href').replace('#dc-shot-', '')));
468
+ });
469
+ });
470
+ select(count);
471
+ document.getElementById('dc-panel-position').addEventListener('input', function(event) { select(Number(event.target.value)); });
472
+ document.querySelectorAll('[data-shot]').forEach(function(button) {
473
+ button.addEventListener('click', function() { select(button.dataset.shot === 'first' ? 1 : button.dataset.shot === 'last' ? count : panelIndex + (button.dataset.shot === 'next' ? 1 : -1)); });
474
+ });
475
+ }
360
476
  function current() {
361
477
  var match = /^#dc-shot-(\\d+)$/.exec(location.hash);
362
478
  return match ? parseInt(match[1], 10) : 0;
363
479
  }
364
480
  document.addEventListener('keydown', function (event) {
481
+ if (document.querySelector('dialog[open]')) return;
365
482
  var n = current();
366
- if (!n) return;
483
+ if (event.target.closest('input, textarea, select, [contenteditable="true"]')) return;
484
+ if (!n) {
485
+ if (panel && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { event.preventDefault(); select(panelIndex + (event.key === 'ArrowRight' ? 1 : -1)); }
486
+ return;
487
+ }
367
488
  if (event.key === 'Escape') { event.preventDefault(); location.hash = 'dc-top'; }
368
489
  else if ((event.key === 'ArrowRight' || event.key === 'ArrowDown') && n < count) { event.preventDefault(); location.hash = 'dc-shot-' + (n + 1); }
369
490
  else if ((event.key === 'ArrowLeft' || event.key === 'ArrowUp') && n > 1) { event.preventDefault(); location.hash = 'dc-shot-' + (n - 1); }
@@ -371,24 +492,36 @@ function renderLightboxScript(count) {
371
492
  })();
372
493
  </script>`;
373
494
  }
495
+ function renderScreenshotPanel(shots) {
496
+ if (!shots.length)
497
+ return '<aside class="dc-screenshots"><div class="dc-empty">No screenshots in this report</div></aside>';
498
+ return `<aside class="dc-screenshots" aria-label="Device screenshots"><div class="dc-panel-toolbar"><a id="dc-panel-expand" href="#dc-shot-${shots.length}" aria-label="Expand screenshot">${ICONS.expand}</a></div><a id="dc-panel-open" href="#dc-shot-${shots.length}"><img id="dc-panel-image" alt="Device screenshot" /></a><p id="dc-panel-caption"></p><input id="dc-panel-position" type="range" min="1" max="${shots.length}" value="${shots.length}" aria-label="Screenshot position" /><nav><button data-shot="first" aria-label="First screenshot">${ICONS.chevron}${ICONS.chevron}</button><button data-shot="previous" aria-label="Previous screenshot">${ICONS.chevron}</button><span id="dc-panel-count"></span><button data-shot="next" aria-label="Next screenshot">${ICONS.chevron}</button><button data-shot="last" aria-label="Last screenshot">${ICONS.chevron}${ICONS.chevron}</button></nav><noscript><a href="#dc-shot-1">Browse screenshots</a></noscript></aside>`;
499
+ }
374
500
  // ---------- summary card ----------
375
501
  function summaryRow(label, value) {
376
502
  return `<div class="dc-sum-cell"><div class="dc-sum-label">${escapeHtml(label)}</div><div class="dc-sum-value">${value}</div></div>`;
377
503
  }
378
- function renderSummaryCard(model) {
379
- const outcome = `<span class="dc-pill dc-pill-${escapeHtml(model.status)}">${escapeHtml(runStatusLabel(model.status))}</span>`;
504
+ function renderSummaryCard(model, showRunDetails) {
505
+ const recorded = (key) => (model.events ?? []).map((event) => event.payload?.[key]).find((value) => ["string", "number", "boolean"].includes(typeof value));
506
+ const result = runSummaryCounts(model.childTests?.length ? model.childTests.flatMap((child) => child.events ?? []) : model.events ?? []);
507
+ const device = recorded("deviceName") ?? "Device not recorded";
380
508
  return `<div class="dc-summary">
381
- ${summaryRow("Suite", escapeHtml(model.suiteName ?? "—"))}
382
- ${summaryRow("Launched On", escapeHtml(formatRunTimestamp(model.timestamp)))}
383
- ${summaryRow("Duration", escapeHtml(formatRunDuration(model.durationSeconds)))}
384
- ${summaryRow("Outcome", outcome)}
385
- ${summaryRow("# Test Cases", String(model.testCases))}
386
- ${summaryRow("% Success Rate", `${Math.round(model.successRate)}%`)}
509
+ <span title="Launched On">${ICONS.calendar}${escapeHtml(formatRunTimestamp(model.timestamp))}</span>
510
+ <span title="Duration">${ICONS.clock}${escapeHtml(formatRunDuration(model.durationSeconds))}</span>
511
+ <span title="Project">${ICONS.folder}${escapeHtml(model.suiteName ?? "—")}</span>
512
+ <span title="Recorded device">${ICONS.device}${escapeHtml(device)}</span>
513
+ ${(model.events ?? []).some(isNetworkCaptureNotice) ? `<span title="Network capture enabled for this run">${ICONS.network}Network capture</span>` : ""}
514
+ ${showRunDetails ? `<details class="dc-run-details"><summary>${ICONS.info}Run details</summary><div class="dc-run-settings">
515
+ ${summaryRow("Model", escapeHtml(recorded("model") ?? recorded("cuaModel") ?? "Not recorded"))}
516
+ ${summaryRow("Platform", escapeHtml(recorded("platform") ?? "Not recorded"))}
517
+ ${result ? summaryRow("Steps", `${result.instructionsCompleted ?? 0}/${result.instructionsTotal ?? 0}`) + summaryRow("Assertions", `${result.assertionsPassed ?? 0}/${(result.assertionsPassed ?? 0) + (result.assertionsFailed ?? 0)} passed`) + summaryRow("Retries", String(result.retries ?? 0)) : ""}
518
+ ${model.childTests?.length || model.testCases > 1 ? summaryRow("Tests passed", `${Math.round(model.successRate * model.testCases / 100)}/${model.testCases}`) : ""}
519
+ </div></details>` : ""}
387
520
  </div>`;
388
521
  }
389
522
  function renderTestsList(childTests) {
390
523
  const rows = childTests
391
- .map((child) => `<div class="dc-test-row"><span class="dc-test-name">${escapeHtml(child.testName.replace(/\.dcua$/i, ""))}</span><span class="dc-test-dur">${escapeHtml(formatRunDuration(child.durationSeconds))}</span><span class="dc-pill dc-pill-${escapeHtml(child.status)}">${escapeHtml(runStatusLabel(child.status))}</span></div>`)
524
+ .map((child, index) => `<a href="#dc-test-${index}" class="dc-test-row"><span class="dc-test-name">${escapeHtml(child.testName.replace(/\.dcua$/i, ""))}</span><span class="dc-test-dur">${escapeHtml(formatRunDuration(child.durationSeconds))}</span><span class="dc-pill dc-pill-${escapeHtml(child.status)}">${escapeHtml(runStatusLabel(child.status))}</span></a>`)
392
525
  .join("\n");
393
526
  return `<div class="dc-tests"><div class="dc-tests-title">Tests</div>${rows}</div>`;
394
527
  }
@@ -397,23 +530,32 @@ const STYLES = `
397
530
  :root { color-scheme: light; }
398
531
  * { box-sizing: border-box; }
399
532
  body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; }
400
- .dc-wrap { max-width: 880px; margin: 0 auto; padding: 24px 20px 64px; }
401
- .dc-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
402
- .dc-title { font-size: 18px; font-weight: 700; }
403
- .dc-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 24px; background: #fff; border: 1px solid #cbd5e1; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
533
+ .dc-wrap { width: 100%; margin: 0 auto; background: #fff; min-height: 100vh; }
534
+ .dc-header { display: flex; align-items: center; gap: 10px; padding: 14px 20px 10px; background: #f7f8ff; }
535
+ .dc-title { font-size: 16px; font-weight: 500; color: #334155; }
536
+ .dc-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 20px; color: #94a3b8; font-size: 12px; padding: 8px 20px 14px; background: #f7f8ff; border-bottom: 1px solid #d8e2f4; }
537
+ .dc-notice { display: flex; align-items: flex-start; gap: 8px; margin: 8px 0; border-radius: 8px; padding: 8px 12px; font-size: 12px; line-height: 20px; background: #f8fafc; color: #64748b; }
538
+ .dc-notice-warning { background: #fffbeb; color: #92400e; }
539
+ .dc-notice > svg { flex-shrink: 0; width: 14px; height: 14px; margin-top: 2px; }
540
+ .dc-notice > div { min-width: 0; }
541
+ .dc-notice p { margin: 0; }
542
+ .dc-notice details { margin-top: 4px; font-size: 11px; }
543
+ .dc-notice summary { cursor: pointer; width: fit-content; }
544
+ .dc-notice pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 4px 0 0; font-size: 11px; }
545
+ .dc-run-details { margin-left: auto; }
546
+ .dc-run-details > summary { cursor: pointer; list-style: none; color: #64748b; }
547
+ .dc-run-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 16px; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; }
404
548
  .dc-sum-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: #1e293b; }
405
549
  .dc-sum-value { font-size: 13px; font-weight: 500; color: #334155; margin-top: 2px; }
406
550
  .dc-tests { background: #fff; border: 1px solid #cbd5e1; border-radius: 12px; padding: 12px 16px; margin-bottom: 16px; }
407
551
  .dc-tests-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: #1e293b; margin-bottom: 8px; }
408
- .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; }
552
+ .dc-test-row { text-decoration: none; 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; }
409
553
  .dc-test-name { font-weight: 500; color: #334155; }
410
554
  .dc-test-dur { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #64748b; font-size: 12px; }
411
- .dc-timeline { background: #fff; border: 1px solid #cbd5e1; border-radius: 12px; padding: 20px 24px; display: flex; flex-direction: column; gap: 8px; }
555
+ .dc-timeline { background: #fff; padding: 24px 20px; display: flex; flex-direction: column; gap: 6px; }
412
556
  .dc-child-block { margin-top: 12px; }
413
557
  .dc-child-head { font-size: 13px; font-weight: 600; color: #334155; margin: 8px 0; }
414
558
  .dc-row { display: flex; }
415
- .dc-row-right { justify-content: flex-end; }
416
- .dc-bubble { display: inline-block; max-width: 70%; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 16px; border-bottom-right-radius: 6px; padding: 8px 14px; color: #0f172a; }
417
559
  .dc-line { display: flex; align-items: center; gap: 8px; color: #475569; }
418
560
  .dc-agent-line { align-items: flex-start; }
419
561
  .dc-agent-line .dc-icon { margin-top: 4px; }
@@ -425,7 +567,7 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
425
567
  .dc-error { color: #b91c1c; }
426
568
  .dc-warning { color: #b45309; }
427
569
  .dc-shot-link { display: inline-flex; align-items: center; justify-content: center; position: relative; min-width: 34px; min-height: 36px; margin-left: 8px; vertical-align: middle; }
428
- .dc-shot-link::before, .dc-lightbox:target::before { content: ""; position: absolute; width: 14px; height: 14px; border: 2px solid #cbd5e1; border-top-color: #475569; border-radius: 50%; animation: dc-spin .7s linear infinite; }
570
+ .dc-shot-link[data-remote-image]::before, .dc-lightbox[data-remote-image]:target::before { content: ""; position: absolute; width: 14px; height: 14px; border: 2px solid #cbd5e1; border-top-color: #475569; border-radius: 50%; animation: dc-spin .7s linear infinite; }
429
571
  .dc-shot { position: relative; z-index: 1; height: 34px; width: auto; border: 1px solid #e2e8f0; border-radius: 4px; cursor: zoom-in; transition: border-color .12s; }
430
572
  .dc-shot-link:hover .dc-shot, .dc-shot-link:focus-visible .dc-shot { border-color: #94a3b8; }
431
573
  .dc-lightbox { display: none; position: fixed; inset: 0; z-index: 1000; align-items: center; justify-content: center; padding: 32px; background: rgba(15, 23, 42, .82); }
@@ -440,20 +582,26 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
440
582
  .dc-card { border: 1px solid; border-radius: 12px; padding: 12px 14px; }
441
583
  .dc-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
442
584
  .dc-card-title { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; }
585
+ .dc-card-title > .dc-icon { color: inherit; width: 16px; height: 16px; }
443
586
  .dc-card-body > div { margin: 1px 0; }
444
- .dc-card-error, .dc-assertion-details { margin-top: 6px; font-size: 13px; }
445
- .dc-assertion { font-size: 14px; }
587
+ .dc-card-error { margin-top: 6px; font-size: 13px; }
588
+ .dc-assertion-fields { margin-top: 8px; font-size: 13px; line-height: 20px; }
589
+ .dc-assertion-fields p { margin: 0; }
590
+ .dc-assertion-fields .dc-agent-line { margin-top: 6px; }
591
+ .dc-expected-label { font-weight: 600; }
592
+ .dc-fail .dc-assertion-fields .dc-agent-line { color: #be123c; }
593
+ .dc-pass .dc-assertion-fields .dc-agent-line { color: #047857; }
446
594
  .dc-pass { border-color: #a7f3d0; background: #ecfdf5; color: #064e3b; }
447
595
  .dc-fail { border-color: #fecdd3; background: #fff1f2; color: #881337; }
448
596
  .dc-amber, .dc-error.dc-card { border-color: #fde68a; background: #fffbeb; color: #78350f; }
449
- .dc-pill { display: inline-flex; width: fit-content; border: 1px solid; border-radius: 999px; padding: 1px 8px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
450
- .dc-pill-pass, .dc-pill-passed { border-color: #6ee7b7; background: #d1fae5; color: #047857; }
451
- .dc-pill-fail, .dc-pill-failed { border-color: #fda4af; background: #ffe4e6; color: #be123c; }
597
+ .dc-pill { display: inline-flex; width: fit-content; border: 1px solid; border-radius: 4px; padding: 2px 8px; font-size: 12px; font-weight: 600; }
598
+ .dc-pill-pass, .dc-pill-passed { border-color: #6ee7b7; background: #ecfdf5; color: #047857; }
599
+ .dc-pill-fail, .dc-pill-failed { border-color: #fda4af; background: #fff1f2; color: #be123c; }
452
600
  .dc-pill-error, .dc-pill-errored, .dc-pill-amber { border-color: #fcd34d; background: #fef3c7; color: #b45309; }
453
601
  .dc-pill-stopped { border-color: #fdba74; background: #ffedd5; color: #c2410c; }
454
602
  .dc-pill-skipped { border-color: #cbd5e1; background: #f1f5f9; color: #334155; }
455
- .dc-pill-ready, .dc-pill-passed { border-color: #6ee7b7; background: #d1fae5; color: #047857; }
456
- .dc-pill-issue, .dc-pill-failed { border-color: #fda4af; background: #ffe4e6; color: #be123c; }
603
+ .dc-pill-ready, .dc-pill-passed { border-color: #6ee7b7; background: #ecfdf5; color: #047857; }
604
+ .dc-pill-issue, .dc-pill-failed { border-color: #fda4af; background: #fff1f2; color: #be123c; }
457
605
  .dc-pill-ended { border-color: #cbd5e1; background: #f1f5f9; color: #334155; }
458
606
  .dc-pill-starting { border-color: #a5b4fc; background: #e0e7ff; color: #4338ca; }
459
607
  .dc-pill-running { border-color: #7dd3fc; background: #e0f2fe; color: #0369a1; }
@@ -464,7 +612,7 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
464
612
  .dc-special[open] > summary::before { transform: rotate(90deg); }
465
613
  .dc-special-title { display: inline-flex; flex: 1; align-items: center; gap: 5px; font-size: 13px; font-weight: 600; }
466
614
  .dc-special-body { margin-top: 9px; display: flex; flex-direction: column; gap: 5px; color: #334155; }
467
- .dc-context { border-color: #ddd6fe; background: #f5f3ff; color: #4c1d95; }
615
+ .dc-context { border-color: #ddd6fe; background: #f5f3ff; color: #4c1d95; margin-bottom: 12px; }
468
616
  .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; }
469
617
  .dc-cloud { border-color: #c7d2fe; background: #eef2ff; color: #312e81; }
470
618
  .dc-network { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
@@ -478,7 +626,64 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
478
626
  .dc-network-data > summary { display: list-item; list-style-position: inside; cursor: pointer; }
479
627
  .dc-network-data pre { max-height: 288px; overflow: auto; white-space: pre-wrap; font-size: 12px; }
480
628
  .dc-network, .dc-loadmill { border-color: #bae6fd; background: #f0f9ff; color: #075985; }
629
+ .dc-loadmill .dc-special-title { color: #075985; }
630
+ .dc-loadmill-passed { border-color: #a7f3d0; background: #ecfdf5; color: #064e3b; }
631
+ .dc-loadmill-failed { border-color: #fecdd3; background: #fff1f2; color: #881337; }
481
632
  .dc-empty { color: #64748b; }
633
+ .dc-report-layout { display: flex; align-items: stretch; }
634
+ .dc-report-main { flex: 1; min-width: 0; overflow-wrap: anywhere; }
635
+ .dc-screenshots { position: sticky; top: 0; width: 32%; min-width: 210px; max-width: none; height: calc(100vh - 100px); display: flex; flex-direction: column; align-items: stretch; padding: 16px 20px; background: #fafbff; border-left: 1px solid #e2e8f0; }
636
+ #dc-panel-open { flex: 1; min-height: 0; display: flex; justify-content: center; padding: 0 12px; }
637
+ #dc-panel-image { max-height: 100%; max-width: 100%; object-fit: contain; border-radius: 8px; border: 1px solid #cbd5e1; box-shadow: 0 1px 2px #0f172a15; }
638
+ .dc-screenshots nav { display: flex; justify-content: space-between; align-items: center; gap: 12px; color: #64748b; font-size: 12px; }
639
+ .dc-screenshots button { border: 0; background: transparent; font-size: 24px; cursor: pointer; color: #475569; }
640
+ .dc-screenshots button:disabled { opacity: .3; cursor: default; }
641
+ #dc-panel-caption { font-size: 12px; color: #475569; margin: 20px 0 12px; }
642
+ .dc-instruction { position: relative; padding: 0 0 10px 32px; }
643
+ .dc-instruction:not(:last-child)::before { content: ""; position: absolute; left: 10px; top: 22px; bottom: -2px; width: 1px; background: #e2e8f0; }
644
+ .dc-instruction > summary { display: flex; align-items: flex-start; gap: 10px; min-height: 24px; cursor: pointer; list-style: none; font-weight: 500; font-size: 14px; }
645
+ .dc-instruction > summary::-webkit-details-marker { display: none; }
646
+ .dc-instruction-status { position: absolute; left: 0; top: 1px; display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: #f1f5f9; color: #94a3b8; }
647
+ .dc-instruction-status .dc-icon { width: 13px; height: 13px; color: inherit; }
648
+ .dc-instruction-status.dc-complete { color: #10b981; background: #ecfdf5; }
649
+ .dc-instruction-status.dc-error { color: #f43f5e; background: #fff1f2; }
650
+ .dc-instruction-status.dc-stopped { color: #f97316; background: #fff7ed; }
651
+ .dc-instruction-duration, .dc-instruction-time { color: #94a3b8; font-size: 12px; font-weight: 400; white-space: nowrap; }
652
+ .dc-instruction-label { min-width: 0; }
653
+ .dc-instruction-duration { display: flex; align-items: center; gap: 6px; line-height: 20px; }
654
+ .dc-instruction-time { margin-left: auto; line-height: 24px; }
655
+ .dc-instruction-activities { display: inline-flex; align-items: center; gap: 6px; margin-left: 4px; padding-left: 8px; border-left: 1px solid #e2e8f0; }
656
+ .dc-activity { display: inline-flex; }
657
+ .dc-activity .dc-icon { width: 12px; height: 12px; color: inherit; overflow: visible; }
658
+ .dc-activity-loadmill path { fill: none; stroke: currentColor; stroke-width: 86; stroke-linejoin: round; }
659
+ .dc-instruction-chevron { display: inline-flex; }
660
+ .dc-instruction[open] > summary .dc-instruction-chevron { transform: rotate(90deg); }
661
+ .dc-instruction-actions { display: flex; flex-direction: column; gap: 12px; padding: 18px 0 8px; }
662
+ .dc-header > .dc-icon { width: 18px; height: 18px; }
663
+ .dc-summary > span, .dc-run-details > summary { display: inline-flex; align-items: center; gap: 7px; }
664
+ .dc-summary .dc-icon { width: 15px; height: 15px; }
665
+ .dc-run-details > summary::-webkit-details-marker { display: none; }
666
+ .dc-panel-toolbar { display: flex; justify-content: flex-end; margin-bottom: 18px; }
667
+ .dc-panel-toolbar a { display: inline-flex; padding: 4px; color: #64748b; }
668
+ #dc-panel-position { width: 100%; margin: 0 0 14px; accent-color: #64748b; height: 4px; cursor: pointer; }
669
+ .dc-screenshots nav button { display: inline-flex; align-items: center; justify-content: center; padding: 4px; }
670
+ .dc-screenshots nav button .dc-icon { color: inherit; width: 14px; height: 14px; }
671
+ .dc-screenshots nav button[data-shot="first"], .dc-screenshots nav button[data-shot="previous"] { transform: rotate(180deg); }
672
+ .dc-screenshots nav button .dc-icon + .dc-icon { margin-left: -8px; }
673
+ #dc-panel-count { flex: 1; text-align: center; }
674
+ .dc-dialog-trigger { display: inline-flex; align-items: center; gap: 7px; border: 0; background: transparent; padding: 4px 8px; font: inherit; color: #64748b; cursor: pointer; border-radius: 4px; }
675
+ .dc-dialog-trigger:hover { background: #f1f5f9; }
676
+ .dc-dialog-trigger .dc-special-title { font-size: inherit; font-weight: 400; }
677
+ .dc-child-head { display: flex; align-items: center; justify-content: space-between; }
678
+ .dc-dialog { width: min(640px, calc(100vw - 40px)); max-height: 80vh; padding: 24px; border: 1px solid #e2e8f0; border-radius: 12px; color: #334155; box-shadow: 0 24px 80px #0f172a30; }
679
+ .dc-dialog::backdrop { background: #0f172a66; }
680
+ .dc-dialog-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
681
+ .dc-dialog-header h2 { margin: 0; font-size: 16px; font-weight: 600; }
682
+ .dc-dialog-header button { border: 0; background: transparent; color: #64748b; font-size: 24px; cursor: pointer; }
683
+ .dc-dialog .dc-run-settings { border: 0; padding: 0; gap: 20px; }
684
+ .dc-dialog pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; }
685
+ @media (max-width: 650px) { .dc-report-layout { flex-direction: column; } .dc-screenshots { position: static; width: 100%; max-width: none; height: 420px; } .dc-timeline { padding: 12px; } .dc-instruction > summary { flex-wrap: wrap; } .dc-instruction-time { display: none; } }
686
+ @media print { .dc-screenshots { display: none; } .dc-instruction::details-content { display: block; } }
482
687
  @keyframes dc-spin { to { transform: rotate(360deg); } }
483
688
  `.trim();
484
689
  // Keep the machine-readable evidence separate from the presentation. Image references
@@ -509,31 +714,30 @@ function renderAgentReport(model, shots) {
509
714
  return `<pre hidden id="agent-report" data-schema-version="1">${escapeHtml(json)}</pre>`;
510
715
  }
511
716
  // ---------- entry point ----------
512
- export function renderHtmlReport(model, { title, includeScripts = true } = {}) {
717
+ export function renderHtmlReport(model, { title, includeScripts = true, showRunDetails = true } = {}) {
513
718
  const docTitle = title ?? `Run Report: ${model.testName ?? model.suiteName ?? "Test run"}`;
514
- const heading = model.testName ?? model.suiteName ?? "Test run";
719
+ const heading = (model.testName ?? model.suiteName ?? "Test run").replace(/\.dcua$/i, "").split(/[\\/]/).pop();
515
720
  // Screenshots are collected across the whole document (top-level + child timelines)
516
721
  // so the lightbox shares one continuous prev/next sequence.
517
722
  const shots = [];
518
723
  let body;
519
724
  if (model.childTests && model.childTests.length > 0) {
520
725
  const children = model.childTests
521
- .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>`)
726
+ .map((child, index) => `<div id="dc-test-${index}" class="dc-child-block"><div class="dc-child-head">${escapeHtml(child.testName.replace(/\.dcua$/i, ""))}</div>${renderTimeline(child.events, shots, child.warnings)}</div>`)
522
727
  .join("\n");
523
- body = `${renderSummaryCard(model)}
524
- ${renderTestsList(model.childTests)}
728
+ body = `${renderTestsList(model.childTests)}
525
729
  <div class="dc-timeline">
526
730
  ${children}
527
731
  </div>`;
528
732
  }
529
733
  else {
530
- body = `${renderSummaryCard(model)}
531
- <div class="dc-timeline">
734
+ body = `<div class="dc-timeline">
532
735
  ${renderTimeline(model.events, shots, model.warnings)}
533
736
  </div>`;
534
737
  }
738
+ body = `<div class="dc-report-main">${body}</div>${includeScripts ? renderScreenshotPanel(shots) : ""}`;
535
739
  const lightboxes = renderLightboxes(shots);
536
- const script = includeScripts ? renderLightboxScript(shots.length) : "";
740
+ const script = includeScripts ? renderInteractionScript(shots.length) : "";
537
741
  return `<!DOCTYPE html>
538
742
  <html lang="en">
539
743
  <head>
@@ -548,8 +752,9 @@ ${STYLES}
548
752
  <!--email_off-->
549
753
  ${renderAgentReport(model, shots)}
550
754
  <div class="dc-wrap" id="dc-top">
551
- <div class="dc-header"><div class="dc-title">Run Details: ${escapeHtml(heading)}</div><span class="dc-pill dc-pill-${escapeHtml(model.status)}">${escapeHtml(runStatusLabel(model.status))}</span></div>
552
- ${body}
755
+ <div class="dc-header">${ICONS.clipboard}<div class="dc-title">${escapeHtml(heading)}</div><span class="dc-pill dc-pill-${escapeHtml(model.status)}">${escapeHtml(runStatusLabel(model.status))}</span></div>
756
+ ${renderSummaryCard(model, showRunDetails)}
757
+ <div class="dc-report-layout">${body}</div>
553
758
  </div>${lightboxes ? `\n${lightboxes}` : ""}${script ? `\n${script}` : ""}
554
759
  <!--/email_off-->
555
760
  </body>