@loadmill/droid-cua 2.16.0 → 2.18.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 +18 -0
- package/build/index.js +88 -1
- package/build/src/browser/playwright/actions.js +17 -8
- package/build/src/cli/headless-execution-config.js +21 -0
- package/build/src/cli/headless-report.js +113 -0
- package/build/src/cli/headless-runner.js +57 -11
- package/build/src/cli/scenario-planner.js +1 -0
- package/build/src/commands/help.js +4 -0
- package/build/src/commands/run.js +2 -1
- package/build/src/core/execution-engine.js +57 -2
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/report/generate-html-report.js +88 -0
- package/build/src/core/report/parse-run-report.js +222 -0
- package/build/src/core/report/render-html-report.js +317 -0
- package/build/src/core/report/report-file-name.js +31 -0
- package/build/src/core/report/run-log-model.js +144 -0
- package/build/src/core/report/run-report-model.js +143 -0
- package/build/src/core/scenario-planner.js +85 -0
- package/build/src/core/secrets.js +116 -0
- package/build/src/core/test-data-import.js +62 -0
- package/build/src/core/test-data.js +103 -0
- package/build/src/core/tool-handlers/close-current-tab.js +3 -3
- package/build/src/core/tool-handlers/navigate.js +3 -3
- package/build/src/core/tool-handlers/preview-click.js +6 -6
- package/build/src/core/tool-handlers/type-sensitive-text.js +62 -0
- package/build/src/core/tool-handlers/utils.js +3 -3
- package/build/src/device/android/actions.js +13 -8
- package/build/src/device/cloud/actions.js +17 -8
- package/build/src/device/ios/actions.js +17 -8
- package/build/src/device/openai.js +17 -0
- package/build/src/device/sensitive-action.js +61 -0
- package/build/src/modes/design-mode-ink.js +3 -1
- package/build/src/modes/design-mode.js +3 -1
- package/build/src/modes/execution-mode.js +1 -0
- package/build/src/prompts/design.js +4 -3
- package/build/src/prompts/execution.js +27 -1
- package/build/src/prompts/scenario-planner.js +48 -0
- package/build/src/prompts/test-data-import.js +49 -0
- package/package.json +1 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Renders a RunReportModel (from parse-run-report.js) into a single, self-contained
|
|
2
|
+
// HTML document that reproduces the Desktop App's run-details view. Pure: no I/O and
|
|
3
|
+
// no external references — screenshots must already be inlined as data URLs in
|
|
4
|
+
// `event.payload.screenshotDataUrl`. Use generate-html-report.js to inline them.
|
|
5
|
+
import { assertionData, formatDuration, hasNearbyAssertionCard, isAssistantAssertionSummary, isDuplicateAssertionLine, isHiddenExecutionLogEvent, runSummaryData, } from "./run-log-model.js";
|
|
6
|
+
// ---------- text / value formatting (mirrors the Desktop App) ----------
|
|
7
|
+
function escapeHtml(value) {
|
|
8
|
+
return String(value ?? "")
|
|
9
|
+
.replace(/&/g, "&")
|
|
10
|
+
.replace(/</g, "<")
|
|
11
|
+
.replace(/>/g, ">")
|
|
12
|
+
.replace(/"/g, """)
|
|
13
|
+
.replace(/'/g, "'");
|
|
14
|
+
}
|
|
15
|
+
function formatRunTimestamp(iso) {
|
|
16
|
+
if (!iso)
|
|
17
|
+
return "—";
|
|
18
|
+
const parsed = Date.parse(iso);
|
|
19
|
+
if (!Number.isFinite(parsed))
|
|
20
|
+
return "—";
|
|
21
|
+
return new Date(parsed).toLocaleString();
|
|
22
|
+
}
|
|
23
|
+
function formatRunDuration(durationSeconds) {
|
|
24
|
+
if (durationSeconds === null || durationSeconds === undefined || !Number.isFinite(durationSeconds)) {
|
|
25
|
+
return "—";
|
|
26
|
+
}
|
|
27
|
+
if (durationSeconds < 60)
|
|
28
|
+
return `${durationSeconds.toFixed(1)}s`;
|
|
29
|
+
const wholeSeconds = Math.floor(durationSeconds);
|
|
30
|
+
const minutes = Math.floor(wholeSeconds / 60);
|
|
31
|
+
const seconds = wholeSeconds % 60;
|
|
32
|
+
if (minutes < 60)
|
|
33
|
+
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
|
|
34
|
+
const hours = Math.floor(minutes / 60);
|
|
35
|
+
const remainingMinutes = minutes % 60;
|
|
36
|
+
return `${hours}h ${remainingMinutes.toString().padStart(2, "0")}m`;
|
|
37
|
+
}
|
|
38
|
+
function runStatusLabel(status) {
|
|
39
|
+
switch (status) {
|
|
40
|
+
case "pass":
|
|
41
|
+
return "Pass";
|
|
42
|
+
case "fail":
|
|
43
|
+
return "Fail";
|
|
44
|
+
case "error":
|
|
45
|
+
return "Error";
|
|
46
|
+
case "stopped":
|
|
47
|
+
return "Stopped";
|
|
48
|
+
case "skipped":
|
|
49
|
+
return "Skipped";
|
|
50
|
+
case "running":
|
|
51
|
+
return "In progress";
|
|
52
|
+
case "pending":
|
|
53
|
+
return "Pending";
|
|
54
|
+
default:
|
|
55
|
+
return "Warning";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ---------- event filtering (visible-events composition for the report) ----------
|
|
59
|
+
// Reduces raw events to the ones the Desktop App actually shows: drops empty-text,
|
|
60
|
+
// hidden-debug media, interactive input prompts, and assistant "ASSERTION RESULT"
|
|
61
|
+
// summaries that sit next to an assertion card, then collapses consecutive duplicate
|
|
62
|
+
// assertion lines (the same assertion is logged several times).
|
|
63
|
+
function buildVisibleEvents(events) {
|
|
64
|
+
const base = (events ?? []).filter((event) => typeof event.text === "string" && event.text.trim().length > 0);
|
|
65
|
+
const filtered = base.filter((event, idx, items) => {
|
|
66
|
+
if (isHiddenExecutionLogEvent(event))
|
|
67
|
+
return false;
|
|
68
|
+
if (isAssistantAssertionSummary(event) && hasNearbyAssertionCard(items, idx))
|
|
69
|
+
return false;
|
|
70
|
+
if (event.eventType === "input_request")
|
|
71
|
+
return false;
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
const visible = [];
|
|
75
|
+
for (const event of filtered) {
|
|
76
|
+
if (isDuplicateAssertionLine(event, visible[visible.length - 1]))
|
|
77
|
+
continue;
|
|
78
|
+
visible.push(event);
|
|
79
|
+
}
|
|
80
|
+
return visible;
|
|
81
|
+
}
|
|
82
|
+
// Emits a clickable thumbnail and registers the screenshot in `shots` so a matching
|
|
83
|
+
// full-size lightbox overlay can be rendered. The href links to the CSS `:target`
|
|
84
|
+
// overlay, so click-to-enlarge works even with JavaScript disabled.
|
|
85
|
+
function screenshotImg(event, shots) {
|
|
86
|
+
const url = event.payload?.screenshotDataUrl;
|
|
87
|
+
if (typeof url !== "string" || !url.startsWith("data:"))
|
|
88
|
+
return "";
|
|
89
|
+
const index = shots.length + 1;
|
|
90
|
+
shots.push({ index, url });
|
|
91
|
+
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>`;
|
|
92
|
+
}
|
|
93
|
+
// ---------- per-event timeline rendering (mirrors ExecutionLogItem.tsx) ----------
|
|
94
|
+
function renderEvent(event, shots) {
|
|
95
|
+
if (isHiddenExecutionLogEvent(event))
|
|
96
|
+
return "";
|
|
97
|
+
const summary = runSummaryData(event);
|
|
98
|
+
if (summary) {
|
|
99
|
+
const cls = summary.status === "passed" ? "pass" : summary.status === "failed" ? "fail" : "amber";
|
|
100
|
+
const label = summary.status === "passed"
|
|
101
|
+
? "Test Passed"
|
|
102
|
+
: summary.status === "stopped"
|
|
103
|
+
? "Test Stopped"
|
|
104
|
+
: summary.status === "errored"
|
|
105
|
+
? "Error"
|
|
106
|
+
: "Test Failed";
|
|
107
|
+
const badge = summary.status === "passed" ? "PASS" : summary.status === "stopped" ? "STOPPED" : summary.status === "errored" ? "ERROR" : "FAIL";
|
|
108
|
+
return `<div class="dc-card dc-${cls}">
|
|
109
|
+
<div class="dc-card-head"><span class="dc-card-title">${escapeHtml(label)}</span><span class="dc-pill dc-pill-${cls}">${escapeHtml(badge)}</span></div>
|
|
110
|
+
<div class="dc-card-body">
|
|
111
|
+
<div>Duration: ${escapeHtml(formatDuration(summary.durationMs))}</div>
|
|
112
|
+
<div>Steps: ${summary.instructionsCompleted ?? 0}/${summary.instructionsTotal ?? 0}</div>
|
|
113
|
+
<div>Assertions: ${summary.assertionsPassed ?? 0}/${(summary.assertionsPassed ?? 0) + (summary.assertionsFailed ?? 0)}</div>
|
|
114
|
+
<div>Retries: ${summary.retries ?? 0}</div>
|
|
115
|
+
</div>${summary.error ? `\n <div class="dc-card-error">${escapeHtml(summary.error)}</div>` : ""}
|
|
116
|
+
</div>`;
|
|
117
|
+
}
|
|
118
|
+
const isInstruction = event.eventType === "instruction_started" || event.kind === "user";
|
|
119
|
+
if (isInstruction) {
|
|
120
|
+
return `<div class="dc-row dc-row-right"><div class="dc-bubble">${escapeHtml(event.text)}</div></div>`;
|
|
121
|
+
}
|
|
122
|
+
const isAssistant = event.eventType === "assistant_message" || event.kind === "assistant";
|
|
123
|
+
if (isAssistant) {
|
|
124
|
+
return `<div class="dc-assistant">${escapeHtml(event.text)}</div>`;
|
|
125
|
+
}
|
|
126
|
+
const assertion = assertionData(event);
|
|
127
|
+
if (assertion) {
|
|
128
|
+
const cls = assertion.passed ? "pass" : "fail";
|
|
129
|
+
const img = screenshotImg(event, shots);
|
|
130
|
+
return `<div class="dc-card dc-${cls}">
|
|
131
|
+
<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>
|
|
132
|
+
<div class="dc-assertion">${escapeHtml(assertion.assertion)}</div>${assertion.details ? `\n <div class="dc-assertion-details">${escapeHtml(assertion.details)}</div>` : ""}
|
|
133
|
+
</div>`;
|
|
134
|
+
}
|
|
135
|
+
if (event.eventType === "retry") {
|
|
136
|
+
return `<div class="dc-line dc-retry">${escapeHtml(event.text)}</div>`;
|
|
137
|
+
}
|
|
138
|
+
if (event.kind === "action") {
|
|
139
|
+
return `<div class="dc-line">${escapeHtml(event.text)}${screenshotImg(event, shots)}</div>`;
|
|
140
|
+
}
|
|
141
|
+
if (event.eventType === "screenshot_captured") {
|
|
142
|
+
return `<div class="dc-line">${escapeHtml(event.text)}${screenshotImg(event, shots)}</div>`;
|
|
143
|
+
}
|
|
144
|
+
return `<div class="dc-line dc-${escapeHtml(event.kind)}">${escapeHtml(event.text)}</div>`;
|
|
145
|
+
}
|
|
146
|
+
function renderTimeline(events, shots) {
|
|
147
|
+
return buildVisibleEvents(events)
|
|
148
|
+
.map((event) => renderEvent(event, shots))
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
.join("\n");
|
|
151
|
+
}
|
|
152
|
+
// Full-size overlays, one per screenshot. Shown via the CSS `:target` rule, so
|
|
153
|
+
// click-to-enlarge and prev/next/close work with JavaScript disabled.
|
|
154
|
+
function renderLightboxes(shots) {
|
|
155
|
+
if (shots.length === 0)
|
|
156
|
+
return "";
|
|
157
|
+
const count = shots.length;
|
|
158
|
+
return shots
|
|
159
|
+
.map((shot) => {
|
|
160
|
+
const prev = shot.index > 1
|
|
161
|
+
? `<a class="dc-lightbox-nav dc-lightbox-prev" href="#dc-shot-${shot.index - 1}" aria-label="Previous screenshot">‹</a>`
|
|
162
|
+
: "";
|
|
163
|
+
const next = shot.index < count
|
|
164
|
+
? `<a class="dc-lightbox-nav dc-lightbox-next" href="#dc-shot-${shot.index + 1}" aria-label="Next screenshot">›</a>`
|
|
165
|
+
: "";
|
|
166
|
+
return `<div class="dc-lightbox" id="dc-shot-${shot.index}">
|
|
167
|
+
<a class="dc-lightbox-backdrop" href="#dc-top" aria-label="Close"></a>
|
|
168
|
+
${prev}
|
|
169
|
+
<img class="dc-lightbox-img" src="${escapeHtml(shot.url)}" alt="Screenshot ${shot.index}" />
|
|
170
|
+
${next}
|
|
171
|
+
<a class="dc-lightbox-close" href="#dc-top" aria-label="Close">×</a>
|
|
172
|
+
</div>`;
|
|
173
|
+
})
|
|
174
|
+
.join("\n");
|
|
175
|
+
}
|
|
176
|
+
// Progressive enhancement: map arrow keys / Escape to the same hash navigation the
|
|
177
|
+
// CSS overlays already use. Inline only — keeps the report a single self-contained file.
|
|
178
|
+
function renderLightboxScript(count) {
|
|
179
|
+
if (count === 0)
|
|
180
|
+
return "";
|
|
181
|
+
return `<script>
|
|
182
|
+
(function () {
|
|
183
|
+
var count = ${count};
|
|
184
|
+
function current() {
|
|
185
|
+
var match = /^#dc-shot-(\\d+)$/.exec(location.hash);
|
|
186
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
187
|
+
}
|
|
188
|
+
document.addEventListener('keydown', function (event) {
|
|
189
|
+
var n = current();
|
|
190
|
+
if (!n) return;
|
|
191
|
+
if (event.key === 'Escape') { event.preventDefault(); location.hash = 'dc-top'; }
|
|
192
|
+
else if ((event.key === 'ArrowRight' || event.key === 'ArrowDown') && n < count) { event.preventDefault(); location.hash = 'dc-shot-' + (n + 1); }
|
|
193
|
+
else if ((event.key === 'ArrowLeft' || event.key === 'ArrowUp') && n > 1) { event.preventDefault(); location.hash = 'dc-shot-' + (n - 1); }
|
|
194
|
+
});
|
|
195
|
+
})();
|
|
196
|
+
</script>`;
|
|
197
|
+
}
|
|
198
|
+
// ---------- summary card ----------
|
|
199
|
+
function summaryRow(label, value) {
|
|
200
|
+
return `<div class="dc-sum-cell"><div class="dc-sum-label">${escapeHtml(label)}</div><div class="dc-sum-value">${value}</div></div>`;
|
|
201
|
+
}
|
|
202
|
+
function renderSummaryCard(model) {
|
|
203
|
+
const outcome = `<span class="dc-pill dc-pill-${escapeHtml(model.status)}">${escapeHtml(runStatusLabel(model.status))}</span>`;
|
|
204
|
+
return `<div class="dc-summary">
|
|
205
|
+
${summaryRow("Suite", escapeHtml(model.suiteName ?? "—"))}
|
|
206
|
+
${summaryRow("Launched On", escapeHtml(formatRunTimestamp(model.timestamp)))}
|
|
207
|
+
${summaryRow("Duration", escapeHtml(formatRunDuration(model.durationSeconds)))}
|
|
208
|
+
${summaryRow("Outcome", outcome)}
|
|
209
|
+
${summaryRow("# Test Cases", String(model.testCases))}
|
|
210
|
+
${summaryRow("% Success Rate", `${Math.round(model.successRate)}%`)}
|
|
211
|
+
</div>`;
|
|
212
|
+
}
|
|
213
|
+
function renderTestsList(childTests) {
|
|
214
|
+
const rows = childTests
|
|
215
|
+
.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>`)
|
|
216
|
+
.join("\n");
|
|
217
|
+
return `<div class="dc-tests"><div class="dc-tests-title">Tests</div>${rows}</div>`;
|
|
218
|
+
}
|
|
219
|
+
// ---------- styles ----------
|
|
220
|
+
const STYLES = `
|
|
221
|
+
:root { color-scheme: light; }
|
|
222
|
+
* { box-sizing: border-box; }
|
|
223
|
+
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; }
|
|
224
|
+
.dc-wrap { max-width: 880px; margin: 0 auto; padding: 24px 20px 64px; }
|
|
225
|
+
.dc-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
|
226
|
+
.dc-title { font-size: 18px; font-weight: 700; }
|
|
227
|
+
.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; }
|
|
228
|
+
.dc-sum-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: #1e293b; }
|
|
229
|
+
.dc-sum-value { font-size: 13px; font-weight: 500; color: #334155; margin-top: 2px; }
|
|
230
|
+
.dc-tests { background: #fff; border: 1px solid #cbd5e1; border-radius: 12px; padding: 12px 16px; margin-bottom: 16px; }
|
|
231
|
+
.dc-tests-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: #1e293b; margin-bottom: 8px; }
|
|
232
|
+
.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; }
|
|
233
|
+
.dc-test-name { font-weight: 500; color: #334155; }
|
|
234
|
+
.dc-test-dur { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #64748b; font-size: 12px; }
|
|
235
|
+
.dc-timeline { background: #fff; border: 1px solid #cbd5e1; border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
|
236
|
+
.dc-child-block { margin-top: 12px; }
|
|
237
|
+
.dc-child-head { font-size: 13px; font-weight: 600; color: #334155; margin: 8px 0; }
|
|
238
|
+
.dc-row { display: flex; }
|
|
239
|
+
.dc-row-right { justify-content: flex-end; }
|
|
240
|
+
.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; }
|
|
241
|
+
.dc-assistant { max-width: 82%; color: #334155; }
|
|
242
|
+
.dc-line { display: flex; align-items: center; gap: 8px; color: #475569; font-size: 13px; }
|
|
243
|
+
.dc-retry { color: #b45309; }
|
|
244
|
+
.dc-error { color: #b91c1c; }
|
|
245
|
+
.dc-warning { color: #b45309; }
|
|
246
|
+
.dc-shot-link { display: inline-flex; margin-left: 8px; vertical-align: middle; }
|
|
247
|
+
.dc-shot { height: 34px; width: auto; border: 1px solid #e2e8f0; border-radius: 4px; cursor: zoom-in; transition: border-color .12s; }
|
|
248
|
+
.dc-shot-link:hover .dc-shot, .dc-shot-link:focus-visible .dc-shot { border-color: #94a3b8; }
|
|
249
|
+
.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); }
|
|
250
|
+
.dc-lightbox:target { display: flex; }
|
|
251
|
+
.dc-lightbox-backdrop { position: absolute; inset: 0; z-index: 1; cursor: zoom-out; }
|
|
252
|
+
.dc-lightbox-img { position: relative; z-index: 2; max-width: 92vw; max-height: 88vh; object-fit: contain; border-radius: 6px; box-shadow: 0 10px 40px rgba(0, 0, 0, .5); }
|
|
253
|
+
.dc-lightbox-close { position: absolute; top: 14px; right: 20px; z-index: 3; color: #fff; font-size: 30px; line-height: 1; text-decoration: none; opacity: .85; }
|
|
254
|
+
.dc-lightbox-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 3; color: #fff; font-size: 44px; line-height: 1; text-decoration: none; padding: 8px 16px; opacity: .85; user-select: none; }
|
|
255
|
+
.dc-lightbox-nav:hover, .dc-lightbox-close:hover { opacity: 1; }
|
|
256
|
+
.dc-lightbox-prev { left: 10px; }
|
|
257
|
+
.dc-lightbox-next { right: 10px; }
|
|
258
|
+
.dc-card { border: 1px solid; border-radius: 12px; padding: 12px 14px; }
|
|
259
|
+
.dc-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
|
|
260
|
+
.dc-card-title { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; }
|
|
261
|
+
.dc-card-body > div { margin: 1px 0; }
|
|
262
|
+
.dc-card-error, .dc-assertion-details { margin-top: 6px; font-size: 13px; }
|
|
263
|
+
.dc-assertion { font-size: 14px; }
|
|
264
|
+
.dc-pass { border-color: #a7f3d0; background: #ecfdf5; color: #064e3b; }
|
|
265
|
+
.dc-fail { border-color: #fecdd3; background: #fff1f2; color: #881337; }
|
|
266
|
+
.dc-amber, .dc-error.dc-card { border-color: #fde68a; background: #fffbeb; color: #78350f; }
|
|
267
|
+
.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; }
|
|
268
|
+
.dc-pill-pass, .dc-pill-passed { border-color: #6ee7b7; background: #d1fae5; color: #047857; }
|
|
269
|
+
.dc-pill-fail, .dc-pill-failed { border-color: #fda4af; background: #ffe4e6; color: #be123c; }
|
|
270
|
+
.dc-pill-error, .dc-pill-errored, .dc-pill-amber { border-color: #fcd34d; background: #fef3c7; color: #b45309; }
|
|
271
|
+
.dc-pill-stopped { border-color: #fdba74; background: #ffedd5; color: #c2410c; }
|
|
272
|
+
.dc-pill-skipped { border-color: #cbd5e1; background: #f1f5f9; color: #334155; }
|
|
273
|
+
`.trim();
|
|
274
|
+
// ---------- entry point ----------
|
|
275
|
+
export function renderHtmlReport(model, { title } = {}) {
|
|
276
|
+
const docTitle = title ?? `Run Report: ${model.testName ?? model.suiteName ?? "Test run"}`;
|
|
277
|
+
const heading = model.testName ?? model.suiteName ?? "Test run";
|
|
278
|
+
// Screenshots are collected across the whole document (top-level + child timelines)
|
|
279
|
+
// so the lightbox shares one continuous prev/next sequence.
|
|
280
|
+
const shots = [];
|
|
281
|
+
let body;
|
|
282
|
+
if (model.childTests && model.childTests.length > 0) {
|
|
283
|
+
const children = model.childTests
|
|
284
|
+
.map((child) => `<div class="dc-child-block"><div class="dc-child-head">${escapeHtml(child.testName.replace(/\.dcua$/i, ""))}</div>${renderTimeline(child.events, shots)}</div>`)
|
|
285
|
+
.join("\n");
|
|
286
|
+
body = `${renderSummaryCard(model)}
|
|
287
|
+
${renderTestsList(model.childTests)}
|
|
288
|
+
<div class="dc-timeline">
|
|
289
|
+
${children}
|
|
290
|
+
</div>`;
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
body = `${renderSummaryCard(model)}
|
|
294
|
+
<div class="dc-timeline">
|
|
295
|
+
${renderTimeline(model.events, shots)}
|
|
296
|
+
</div>`;
|
|
297
|
+
}
|
|
298
|
+
const lightboxes = renderLightboxes(shots);
|
|
299
|
+
const script = renderLightboxScript(shots.length);
|
|
300
|
+
return `<!DOCTYPE html>
|
|
301
|
+
<html lang="en">
|
|
302
|
+
<head>
|
|
303
|
+
<meta charset="utf-8" />
|
|
304
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
305
|
+
<title>${escapeHtml(docTitle)}</title>
|
|
306
|
+
<style>
|
|
307
|
+
${STYLES}
|
|
308
|
+
</style>
|
|
309
|
+
</head>
|
|
310
|
+
<body>
|
|
311
|
+
<div class="dc-wrap" id="dc-top">
|
|
312
|
+
<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>
|
|
313
|
+
${body}
|
|
314
|
+
</div>${lightboxes ? `\n${lightboxes}` : ""}${script ? `\n${script}` : ""}
|
|
315
|
+
</body>
|
|
316
|
+
</html>`;
|
|
317
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Shared, human-readable filename for downloaded HTML run reports. Used by both the
|
|
2
|
+
// CLI (`--report`) and the Desktop App ("Share" → "Download HTML report") so the two
|
|
3
|
+
// produce identical, conventionally-named files: <test-name>--<date>--<time>.html
|
|
4
|
+
// e.g. `a--2026-06-03--16-09-20.html`.
|
|
5
|
+
function sanitizeStem(value) {
|
|
6
|
+
return (String(value || "report")
|
|
7
|
+
.replace(/\.dcua$/i, "")
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
10
|
+
.replace(/-+/g, "-")
|
|
11
|
+
.replace(/^[-_.]+|[-_.]+$/g, "") || "report");
|
|
12
|
+
}
|
|
13
|
+
// Returns { datePart: "YYYY-MM-DD", timePart: "HH-MM-SS" } in local time. Falls back to
|
|
14
|
+
// "now" when the timestamp is missing or unparseable.
|
|
15
|
+
function formatStamp(iso) {
|
|
16
|
+
const parsed = iso ? new Date(iso) : null;
|
|
17
|
+
const valid = parsed && Number.isFinite(parsed.getTime()) ? parsed : new Date();
|
|
18
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
19
|
+
return {
|
|
20
|
+
datePart: `${valid.getFullYear()}-${pad(valid.getMonth() + 1)}-${pad(valid.getDate())}`,
|
|
21
|
+
timePart: `${pad(valid.getHours())}-${pad(valid.getMinutes())}-${pad(valid.getSeconds())}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// Builds the report filename from a run's name + timestamp. `testName` wins; `suiteName`
|
|
25
|
+
// is the fallback (mirrors the report heading), then a generic "report". The name uses
|
|
26
|
+
// single dashes; `--` separates name↔date↔time so the segments stay visually distinct.
|
|
27
|
+
export function buildReportFileName({ testName, suiteName, timestamp } = {}) {
|
|
28
|
+
const name = sanitizeStem(testName ?? suiteName);
|
|
29
|
+
const { datePart, timePart } = formatStamp(timestamp);
|
|
30
|
+
return `${name}--${datePart}--${timePart}.html`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Pure leaf classifier predicates for execution log events.
|
|
2
|
+
// Shared by the Desktop App (log-utils.ts re-exports these) and the HTML report renderer.
|
|
3
|
+
// No I/O, no React, no side effects.
|
|
4
|
+
import { classifyCaptureSource } from "../capture-sources.js";
|
|
5
|
+
export function captureSourceOf(line) {
|
|
6
|
+
const captureSource = line.payload?.captureSource;
|
|
7
|
+
return typeof captureSource === "string" && captureSource.trim().length > 0 ? captureSource : null;
|
|
8
|
+
}
|
|
9
|
+
export function mediaKindOf(line) {
|
|
10
|
+
const captureSource = captureSourceOf(line);
|
|
11
|
+
const classification = classifyCaptureSource(captureSource);
|
|
12
|
+
const mediaKind = classification.mediaKind;
|
|
13
|
+
if (mediaKind === "hidden-debug" || mediaKind === "click-annotation" || mediaKind === "screenshot") {
|
|
14
|
+
return mediaKind;
|
|
15
|
+
}
|
|
16
|
+
// Backcompat bridge for older logs and reports that predate captureSource normalization.
|
|
17
|
+
const toolName = line.payload?.toolName;
|
|
18
|
+
if (toolName === "preview_click") {
|
|
19
|
+
return "hidden-debug";
|
|
20
|
+
}
|
|
21
|
+
const hasScreenshot = typeof line.payload?.screenshotDataUrl === "string" ||
|
|
22
|
+
typeof line.payload?.screenshotPath === "string";
|
|
23
|
+
if (!hasScreenshot) {
|
|
24
|
+
return "none";
|
|
25
|
+
}
|
|
26
|
+
if (line.eventType === "screenshot_captured" || line.eventType === "assertion_result") {
|
|
27
|
+
return "screenshot";
|
|
28
|
+
}
|
|
29
|
+
if (line.kind === "action") {
|
|
30
|
+
return "click-annotation";
|
|
31
|
+
}
|
|
32
|
+
return "none";
|
|
33
|
+
}
|
|
34
|
+
export function isHiddenExecutionLogEvent(line) {
|
|
35
|
+
return mediaKindOf(line) === "hidden-debug";
|
|
36
|
+
}
|
|
37
|
+
export function isGalleryMediaEvent(line) {
|
|
38
|
+
const mediaKind = mediaKindOf(line);
|
|
39
|
+
return mediaKind === "click-annotation" || mediaKind === "screenshot";
|
|
40
|
+
}
|
|
41
|
+
export function cleanAssertionDetails(rawDetails, assertion, passed) {
|
|
42
|
+
if (!rawDetails)
|
|
43
|
+
return null;
|
|
44
|
+
let details = rawDetails
|
|
45
|
+
.replace(/\[(Assertion|Assistant)\]\s*/gi, "")
|
|
46
|
+
.replace(/\r/g, "")
|
|
47
|
+
.trim();
|
|
48
|
+
const lines = details
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((line) => line.trim())
|
|
51
|
+
.filter((line) => line.length > 0 && !/assertion result:/i.test(line));
|
|
52
|
+
details = lines.join(" ").replace(/\s{2,}/g, " ").trim();
|
|
53
|
+
if (!passed && /assertion result:\s*pass/i.test(rawDetails)) {
|
|
54
|
+
details = details.replace(/assertion result:\s*pass.*$/i, "").trim();
|
|
55
|
+
}
|
|
56
|
+
if (passed && /assertion result:\s*fail/i.test(rawDetails)) {
|
|
57
|
+
details = details.replace(/assertion result:\s*fail.*$/i, "").trim();
|
|
58
|
+
}
|
|
59
|
+
if (details.toLowerCase() === assertion.trim().toLowerCase())
|
|
60
|
+
return null;
|
|
61
|
+
return details.length > 0 ? details : null;
|
|
62
|
+
}
|
|
63
|
+
export function assertionData(event) {
|
|
64
|
+
if (event.eventType !== "assertion_result")
|
|
65
|
+
return null;
|
|
66
|
+
const payload = event.payload ?? {};
|
|
67
|
+
const assertion = typeof payload.assertion === "string" && payload.assertion.trim().length > 0
|
|
68
|
+
? payload.assertion
|
|
69
|
+
: event.text;
|
|
70
|
+
const passed = Boolean(payload.passed);
|
|
71
|
+
const rawDetails = typeof payload.details === "string" && payload.details.trim().length > 0
|
|
72
|
+
? payload.details
|
|
73
|
+
: null;
|
|
74
|
+
return { assertion, details: cleanAssertionDetails(rawDetails, assertion, passed), passed };
|
|
75
|
+
}
|
|
76
|
+
export function runSummaryData(event) {
|
|
77
|
+
if (event.eventType !== "run_finished")
|
|
78
|
+
return null;
|
|
79
|
+
const payload = event.payload ?? {};
|
|
80
|
+
const success = Boolean(payload.success);
|
|
81
|
+
const error = typeof payload.error === "string" && payload.error.trim().length > 0 ? payload.error : null;
|
|
82
|
+
const stopped = !success && Boolean(error && error.toLowerCase().includes("stopped by user"));
|
|
83
|
+
const isAssertionFailure = Boolean(error && error.startsWith("Assertion failed:"));
|
|
84
|
+
return {
|
|
85
|
+
status: success ? "passed" : stopped ? "stopped" : isAssertionFailure ? "failed" : "errored",
|
|
86
|
+
durationMs: typeof payload.durationMs === "number" ? payload.durationMs : null,
|
|
87
|
+
instructionsTotal: typeof payload.instructionsTotal === "number" ? payload.instructionsTotal : null,
|
|
88
|
+
instructionsCompleted: typeof payload.instructionsCompleted === "number" ? payload.instructionsCompleted : null,
|
|
89
|
+
assertionsPassed: typeof payload.assertionsPassed === "number" ? payload.assertionsPassed : null,
|
|
90
|
+
assertionsFailed: typeof payload.assertionsFailed === "number" ? payload.assertionsFailed : null,
|
|
91
|
+
retries: typeof payload.retries === "number" ? payload.retries : null,
|
|
92
|
+
error,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function formatDuration(ms) {
|
|
96
|
+
if (ms === null || ms === undefined)
|
|
97
|
+
return "n/a";
|
|
98
|
+
const totalSeconds = ms / 1000;
|
|
99
|
+
if (totalSeconds < 60)
|
|
100
|
+
return `${totalSeconds.toFixed(1)}s`;
|
|
101
|
+
const wholeSeconds = Math.floor(totalSeconds);
|
|
102
|
+
const minutes = Math.floor(wholeSeconds / 60);
|
|
103
|
+
const seconds = wholeSeconds % 60;
|
|
104
|
+
if (minutes < 60)
|
|
105
|
+
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
|
|
106
|
+
const hours = Math.floor(minutes / 60);
|
|
107
|
+
const remainingMinutes = minutes % 60;
|
|
108
|
+
return `${hours}h ${remainingMinutes.toString().padStart(2, "0")}m`;
|
|
109
|
+
}
|
|
110
|
+
export function isAssistantAssertionSummary(event) {
|
|
111
|
+
if (!(event.eventType === "assistant_message" || event.kind === "assistant"))
|
|
112
|
+
return false;
|
|
113
|
+
const text = event.text.toLowerCase();
|
|
114
|
+
return text.includes("assertion result: pass") || text.includes("assertion result: fail");
|
|
115
|
+
}
|
|
116
|
+
export function hasNearbyAssertionCard(events, index) {
|
|
117
|
+
const current = events[index];
|
|
118
|
+
const windowStart = Math.max(0, index - 3);
|
|
119
|
+
const windowEnd = Math.min(events.length - 1, index + 3);
|
|
120
|
+
for (let i = windowStart; i <= windowEnd; i++) {
|
|
121
|
+
if (i === index)
|
|
122
|
+
continue;
|
|
123
|
+
const candidate = events[i];
|
|
124
|
+
if (candidate.eventType !== "assertion_result")
|
|
125
|
+
continue;
|
|
126
|
+
if (current.stepId && candidate.stepId) {
|
|
127
|
+
if (current.stepId === candidate.stepId)
|
|
128
|
+
return true;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
export function isDuplicateAssertionLine(event, previous) {
|
|
136
|
+
if (event.eventType !== "assertion_result" || !previous || previous.eventType !== "assertion_result") {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const current = assertionData(event);
|
|
140
|
+
const prev = assertionData(previous);
|
|
141
|
+
if (!current || !prev)
|
|
142
|
+
return false;
|
|
143
|
+
return current.assertion === prev.assertion && current.passed === prev.passed;
|
|
144
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The single, shared model-assembler for run reports. Both producers — the XML/Desktop
|
|
2
|
+
// path (parse-run-report.js) and the CLI path (headless-report.js) — acquire raw run data
|
|
3
|
+
// their own way (parse stored JUnit XML vs. capture live events), then delegate ALL model
|
|
4
|
+
// assembly to the pure functions here. This guarantees the two paths derive status,
|
|
5
|
+
// success rate, failure shape, and event normalization identically (structural parity,
|
|
6
|
+
// not coincidence), so the shared renderer produces the same HTML from either source.
|
|
7
|
+
//
|
|
8
|
+
// Pure: no I/O, no XML, no React. The canonical normalized event shape is
|
|
9
|
+
// { ts, kind, text, eventType?, actionType?, payload? }
|
|
10
|
+
// and the assembled model is a RunReportModel (see assembleRunReportModel).
|
|
11
|
+
const VALID_KINDS = new Set([
|
|
12
|
+
"user",
|
|
13
|
+
"assistant",
|
|
14
|
+
"reasoning",
|
|
15
|
+
"action",
|
|
16
|
+
"warning",
|
|
17
|
+
"info",
|
|
18
|
+
"error",
|
|
19
|
+
"success",
|
|
20
|
+
"system",
|
|
21
|
+
"muted",
|
|
22
|
+
]);
|
|
23
|
+
const VALID_EVENTS = new Set([
|
|
24
|
+
"run_started",
|
|
25
|
+
"instruction_started",
|
|
26
|
+
"reasoning",
|
|
27
|
+
"assistant_message",
|
|
28
|
+
"tool_call",
|
|
29
|
+
"screenshot_captured",
|
|
30
|
+
"assertion_result",
|
|
31
|
+
"retry",
|
|
32
|
+
"input_request",
|
|
33
|
+
"run_finished",
|
|
34
|
+
"error",
|
|
35
|
+
"system_message",
|
|
36
|
+
"design_started",
|
|
37
|
+
"design_status",
|
|
38
|
+
"design_waiting_for_input",
|
|
39
|
+
"design_user_input",
|
|
40
|
+
"design_generated_script",
|
|
41
|
+
"design_saved",
|
|
42
|
+
"design_error",
|
|
43
|
+
"design_finished",
|
|
44
|
+
]);
|
|
45
|
+
const VALID_ACTIONS = new Set([
|
|
46
|
+
"click",
|
|
47
|
+
"double_click",
|
|
48
|
+
"drag",
|
|
49
|
+
"scroll",
|
|
50
|
+
"type",
|
|
51
|
+
"keypress",
|
|
52
|
+
"wait",
|
|
53
|
+
"screenshot",
|
|
54
|
+
"move",
|
|
55
|
+
]);
|
|
56
|
+
// Normalizes one raw event (already mapped to canonical field names) into the shared
|
|
57
|
+
// shape, validating kind/eventType/actionType against the known sets and dropping events
|
|
58
|
+
// with no usable text. Returns null for a dropped event (and pushes a warning, if a
|
|
59
|
+
// `warnings` array is provided). Callers map their producer-specific field names
|
|
60
|
+
// (NDJSON `event`/`action`, CLI `type`) to `eventType`/`actionType`/`kind` before calling.
|
|
61
|
+
export function normalizeEvent(value, { fallbackTs = null, warnings } = {}) {
|
|
62
|
+
const text = typeof value.text === "string" ? value.text : null;
|
|
63
|
+
if (!text || !text.trim()) {
|
|
64
|
+
warnings?.push("Skipped one event line due to missing text.");
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const ts = typeof value.ts === "string" && value.ts.trim().length > 0 ? value.ts : fallbackTs;
|
|
68
|
+
const kind = typeof value.kind === "string" && VALID_KINDS.has(value.kind) ? value.kind : "info";
|
|
69
|
+
const eventType = typeof value.eventType === "string" && VALID_EVENTS.has(value.eventType) ? value.eventType : undefined;
|
|
70
|
+
const actionType = typeof value.actionType === "string" && VALID_ACTIONS.has(value.actionType) ? value.actionType : undefined;
|
|
71
|
+
const payload = value.payload && typeof value.payload === "object" && !Array.isArray(value.payload)
|
|
72
|
+
? value.payload
|
|
73
|
+
: undefined;
|
|
74
|
+
return { ts, kind, text, eventType, actionType, payload };
|
|
75
|
+
}
|
|
76
|
+
// Maps a list of raw events through normalizeEvent, dropping the ones that fail.
|
|
77
|
+
export function normalizeEvents(values, options = {}) {
|
|
78
|
+
const events = [];
|
|
79
|
+
for (const value of values) {
|
|
80
|
+
const event = normalizeEvent(value, options);
|
|
81
|
+
if (event)
|
|
82
|
+
events.push(event);
|
|
83
|
+
}
|
|
84
|
+
return events;
|
|
85
|
+
}
|
|
86
|
+
// The single status precedence ladder: error > fail > stopped > skipped > pass.
|
|
87
|
+
// `failureKind` is the kind of any parsed <failure>/<error> node (XML) or the CLI's
|
|
88
|
+
// derived failure kind; it lets a run with no count attributes still resolve correctly.
|
|
89
|
+
export function deriveRunStatus({ tests = 0, failures = 0, errors = 0, skipped = 0, stopped = false, hasSkippedNode = false, failureKind, } = {}) {
|
|
90
|
+
if (errors > 0 || failureKind === "error")
|
|
91
|
+
return "error";
|
|
92
|
+
if (failures > 0 || failureKind === "failure")
|
|
93
|
+
return "fail";
|
|
94
|
+
if (stopped)
|
|
95
|
+
return "stopped";
|
|
96
|
+
if (skipped >= tests || (hasSkippedNode && tests <= 1))
|
|
97
|
+
return "skipped";
|
|
98
|
+
return "pass";
|
|
99
|
+
}
|
|
100
|
+
// The single success-rate formula: passed / tests, falling back to status when there are
|
|
101
|
+
// no test counts. `passed` = tests minus failures/errors/skipped (clamped at 0).
|
|
102
|
+
export function computeSuccessRate({ tests = 0, failures = 0, errors = 0, skipped = 0, status } = {}) {
|
|
103
|
+
const passed = Math.max(0, tests - failures - errors - skipped);
|
|
104
|
+
return tests > 0 ? (passed / tests) * 100 : status === "pass" ? 100 : 0;
|
|
105
|
+
}
|
|
106
|
+
// The single failureOrError constructor. Returns undefined when there is no failure kind.
|
|
107
|
+
export function makeFailureOrError({ kind, type, message, body } = {}) {
|
|
108
|
+
if (!kind)
|
|
109
|
+
return undefined;
|
|
110
|
+
return { kind, type, message, body };
|
|
111
|
+
}
|
|
112
|
+
// The single run_finished payload contract — the shape the shared renderer's
|
|
113
|
+
// runSummaryData() consumes. Producers map their own stat names to these canonical fields.
|
|
114
|
+
export function buildRunFinishedPayload({ success, error, durationMs, instructionsTotal, instructionsCompleted, actionsTotal, assertionsPassed, assertionsFailed, retries, } = {}) {
|
|
115
|
+
return {
|
|
116
|
+
success: Boolean(success),
|
|
117
|
+
error: error ?? null,
|
|
118
|
+
durationMs,
|
|
119
|
+
instructionsTotal,
|
|
120
|
+
instructionsCompleted: instructionsCompleted ?? 0,
|
|
121
|
+
actionsTotal: actionsTotal ?? 0,
|
|
122
|
+
assertionsPassed: assertionsPassed ?? 0,
|
|
123
|
+
assertionsFailed: assertionsFailed ?? 0,
|
|
124
|
+
retries: retries ?? 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Assembles the final RunReportModel from already-derived parts. Both producers end here so
|
|
128
|
+
// the top-level shape is identical regardless of source.
|
|
129
|
+
export function assembleRunReportModel({ suiteName = null, testName = null, timestamp = null, durationSeconds = null, status, testCases, successRate, failureOrError, events = [], childTests, warnings = [], } = {}) {
|
|
130
|
+
return {
|
|
131
|
+
suiteName,
|
|
132
|
+
testName,
|
|
133
|
+
timestamp,
|
|
134
|
+
durationSeconds,
|
|
135
|
+
status,
|
|
136
|
+
testCases,
|
|
137
|
+
successRate,
|
|
138
|
+
failureOrError,
|
|
139
|
+
events,
|
|
140
|
+
childTests,
|
|
141
|
+
warnings,
|
|
142
|
+
};
|
|
143
|
+
}
|