@loadmill/droid-cua 2.17.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.
Files changed (33) hide show
  1. package/README.md +18 -0
  2. package/build/index.js +16 -1
  3. package/build/src/browser/playwright/actions.js +17 -8
  4. package/build/src/cli/headless-execution-config.js +6 -0
  5. package/build/src/cli/headless-report.js +113 -0
  6. package/build/src/cli/headless-runner.js +40 -6
  7. package/build/src/commands/help.js +1 -0
  8. package/build/src/commands/run.js +2 -1
  9. package/build/src/core/execution-engine.js +57 -2
  10. package/build/src/core/prompts.js +1 -1
  11. package/build/src/core/report/generate-html-report.js +88 -0
  12. package/build/src/core/report/parse-run-report.js +222 -0
  13. package/build/src/core/report/render-html-report.js +317 -0
  14. package/build/src/core/report/report-file-name.js +31 -0
  15. package/build/src/core/report/run-log-model.js +144 -0
  16. package/build/src/core/report/run-report-model.js +143 -0
  17. package/build/src/core/secrets.js +116 -0
  18. package/build/src/core/tool-handlers/close-current-tab.js +3 -3
  19. package/build/src/core/tool-handlers/navigate.js +3 -3
  20. package/build/src/core/tool-handlers/preview-click.js +6 -6
  21. package/build/src/core/tool-handlers/type-sensitive-text.js +62 -0
  22. package/build/src/core/tool-handlers/utils.js +3 -3
  23. package/build/src/device/android/actions.js +13 -8
  24. package/build/src/device/cloud/actions.js +17 -8
  25. package/build/src/device/ios/actions.js +17 -8
  26. package/build/src/device/openai.js +17 -0
  27. package/build/src/device/sensitive-action.js +61 -0
  28. package/build/src/modes/design-mode-ink.js +3 -1
  29. package/build/src/modes/design-mode.js +3 -1
  30. package/build/src/modes/execution-mode.js +1 -0
  31. package/build/src/prompts/design.js +4 -3
  32. package/build/src/prompts/execution.js +15 -1
  33. package/package.json +1 -1
@@ -0,0 +1,222 @@
1
+ // Parses a JUnit-style run-result XML (the format stored under a project's
2
+ // results/ directory) into a normalized RunReportModel. This is the XML-acquisition
3
+ // adapter: it does the regex/attr/NDJSON parsing, then delegates ALL model assembly
4
+ // (event normalization, status, success rate, failure shape, final shape) to the shared
5
+ // run-report-model.js so the desktop live view, the Share report, and the CLI report all
6
+ // derive identically.
7
+ import { normalizeEvent, deriveRunStatus, computeSuccessRate, makeFailureOrError, assembleRunReportModel, } from "./run-report-model.js";
8
+ function parseAttributes(raw) {
9
+ const attrs = {};
10
+ const attrPattern = /([^\s=]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
11
+ let match = null;
12
+ while ((match = attrPattern.exec(raw)) !== null) {
13
+ attrs[match[1]] = match[3] ?? match[4] ?? "";
14
+ }
15
+ return attrs;
16
+ }
17
+ function findOpenTag(xml, tagName) {
18
+ const pattern = new RegExp(`<${tagName}\\b([^>]*)>`, "i");
19
+ const match = xml.match(pattern);
20
+ if (!match)
21
+ return null;
22
+ return parseAttributes(match[1] ?? "");
23
+ }
24
+ function findFirstTagWithBody(xml, tagName) {
25
+ const pattern = new RegExp(`<${tagName}\\b([^>]*)>([\\s\\S]*?)<\\/${tagName}>`, "i");
26
+ const match = xml.match(pattern);
27
+ if (!match)
28
+ return null;
29
+ return { attrs: parseAttributes(match[1] ?? ""), body: match[2] ?? "" };
30
+ }
31
+ function findFirstTestcase(xml) {
32
+ const withBody = findFirstTagWithBody(xml, "testcase");
33
+ if (withBody)
34
+ return withBody;
35
+ const selfClosing = xml.match(/<testcase\b([^>]*)\/>/i);
36
+ if (!selfClosing)
37
+ return null;
38
+ return { attrs: parseAttributes(selfClosing[1] ?? ""), body: "" };
39
+ }
40
+ function findTestcases(xml) {
41
+ const testcases = [];
42
+ const withBodyPattern = /<testcase\b([^>]*)>([\s\S]*?)<\/testcase>/gi;
43
+ let match = null;
44
+ while ((match = withBodyPattern.exec(xml)) !== null) {
45
+ testcases.push({ attrs: parseAttributes(match[1] ?? ""), body: match[2] ?? "" });
46
+ }
47
+ if (testcases.length > 0)
48
+ return testcases;
49
+ const selfClosingPattern = /<testcase\b([^>]*)\/>/gi;
50
+ while ((match = selfClosingPattern.exec(xml)) !== null) {
51
+ testcases.push({ attrs: parseAttributes(match[1] ?? ""), body: "" });
52
+ }
53
+ return testcases;
54
+ }
55
+ function parseCount(raw) {
56
+ if (!raw)
57
+ return null;
58
+ const parsed = Number.parseInt(raw, 10);
59
+ return Number.isFinite(parsed) ? parsed : null;
60
+ }
61
+ function parseDuration(raw) {
62
+ if (!raw)
63
+ return null;
64
+ const parsed = Number.parseFloat(raw);
65
+ return Number.isFinite(parsed) ? parsed : null;
66
+ }
67
+ function countTestcases(xml) {
68
+ return xml.match(/<testcase\b/gi)?.length ?? 0;
69
+ }
70
+ function readFailureOrError(testcaseBody) {
71
+ const failure = findFirstTagWithBody(testcaseBody, "failure");
72
+ if (failure) {
73
+ return makeFailureOrError({
74
+ kind: "failure",
75
+ type: failure.attrs.type,
76
+ message: failure.attrs.message,
77
+ body: failure.body.trim() || undefined,
78
+ });
79
+ }
80
+ const error = findFirstTagWithBody(testcaseBody, "error");
81
+ if (error) {
82
+ return makeFailureOrError({
83
+ kind: "error",
84
+ type: error.attrs.type,
85
+ message: error.attrs.message,
86
+ body: error.body.trim() || undefined,
87
+ });
88
+ }
89
+ return undefined;
90
+ }
91
+ function hasStoppedMarker(value) {
92
+ return /stopped by user/i.test(value);
93
+ }
94
+ // Maps one parsed NDJSON line (producer field names `event`/`action`) to the canonical
95
+ // event shape and runs it through the shared normalizer.
96
+ function normalizeNdjsonEvent(value, fallbackTs, warnings) {
97
+ return normalizeEvent({
98
+ ts: value.ts,
99
+ kind: value.kind,
100
+ text: value.text,
101
+ eventType: value.event,
102
+ actionType: value.action,
103
+ payload: value.payload,
104
+ }, { fallbackTs, warnings });
105
+ }
106
+ function parseSystemOutEvents(systemOutBody, fallbackTs, warnings) {
107
+ const normalizedSystemOut = systemOutBody
108
+ ?.trim()
109
+ .replace(/^<!\[CDATA\[/, "")
110
+ .replace(/\]\]>$/, "");
111
+ if (!normalizedSystemOut || !normalizedSystemOut.trim()) {
112
+ warnings.push("Event log not available (older report format).");
113
+ return [];
114
+ }
115
+ const lines = normalizedSystemOut
116
+ .split("\n")
117
+ .map((line) => line.trim())
118
+ .filter((line) => line.length > 0);
119
+ if (lines.length === 0) {
120
+ warnings.push("Event log not available (older report format).");
121
+ return [];
122
+ }
123
+ const events = [];
124
+ let invalidJsonCount = 0;
125
+ for (const line of lines) {
126
+ try {
127
+ const parsed = JSON.parse(line);
128
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
129
+ invalidJsonCount += 1;
130
+ continue;
131
+ }
132
+ const event = normalizeNdjsonEvent(parsed, fallbackTs, warnings);
133
+ if (event)
134
+ events.push(event);
135
+ }
136
+ catch {
137
+ invalidJsonCount += 1;
138
+ }
139
+ }
140
+ if (invalidJsonCount > 0) {
141
+ warnings.push(`Skipped ${invalidJsonCount} malformed event line${invalidJsonCount === 1 ? "" : "s"}.`);
142
+ }
143
+ return events;
144
+ }
145
+ export function parseRunReport(xml, { fallbackTimestamp = new Date(0).toISOString() } = {}) {
146
+ const warnings = [];
147
+ const suitesAttrs = findOpenTag(xml, "testsuites");
148
+ const suite = findFirstTagWithBody(xml, "testsuite");
149
+ if (!suite) {
150
+ throw new Error("Missing <testsuite> element.");
151
+ }
152
+ const testcases = findTestcases(suite.body);
153
+ const testcase = testcases[0] ?? findFirstTestcase(suite.body);
154
+ if (!testcase) {
155
+ warnings.push("Missing <testcase> element; using suite-level data only.");
156
+ }
157
+ const tests = (parseCount(suite.attrs.tests ?? suitesAttrs?.tests) ?? countTestcases(suite.body)) || 1;
158
+ const failures = parseCount(suite.attrs.failures ?? suitesAttrs?.failures) ?? 0;
159
+ const errors = parseCount(suite.attrs.errors ?? suitesAttrs?.errors) ?? 0;
160
+ const skipped = parseCount(suite.attrs.skipped ?? suitesAttrs?.skipped) ?? 0;
161
+ const failureOrError = testcases.map((entry) => readFailureOrError(entry.body)).find(Boolean) ??
162
+ (testcase ? readFailureOrError(testcase.body) : undefined);
163
+ const hasSkippedNode = testcase ? /<skipped\b/i.test(testcase.body) : false;
164
+ const stopped = testcases.some((entry) => hasStoppedMarker(entry.body)) ||
165
+ (testcase ? hasStoppedMarker(testcase.body) : false);
166
+ const status = deriveRunStatus({
167
+ tests,
168
+ failures,
169
+ errors,
170
+ skipped,
171
+ stopped,
172
+ hasSkippedNode,
173
+ failureKind: failureOrError?.kind,
174
+ });
175
+ const successRate = computeSuccessRate({ tests, failures, errors, skipped, status });
176
+ const timestamp = suite.attrs.timestamp ?? suitesAttrs?.timestamp ?? null;
177
+ const detailDuration = testcases.length > 1
178
+ ? parseDuration(suite.attrs.time ?? suitesAttrs?.time)
179
+ : parseDuration(testcase?.attrs.time) ?? parseDuration(suite.attrs.time ?? suitesAttrs?.time);
180
+ const systemOut = findFirstTagWithBody(testcase?.body ?? suite.body, "system-out");
181
+ const events = parseSystemOutEvents(systemOut?.body ?? null, timestamp ?? fallbackTimestamp, warnings);
182
+ const childTests = testcases.length > 1
183
+ ? testcases.map((child, index) => {
184
+ const childWarnings = [];
185
+ const childFailureOrError = readFailureOrError(child.body);
186
+ const childHasSkippedNode = /<skipped\b/i.test(child.body);
187
+ const childStopped = hasStoppedMarker(child.body);
188
+ // childTests is XML-only (the CLI never produces it); successRate stays a binary
189
+ // pass/non-pass here by design — only the status ladder is shared.
190
+ const childStatus = deriveRunStatus({
191
+ tests: 1,
192
+ stopped: childStopped,
193
+ hasSkippedNode: childHasSkippedNode,
194
+ failureKind: childFailureOrError?.kind,
195
+ });
196
+ const childSystemOut = findFirstTagWithBody(child.body, "system-out");
197
+ const childEvents = parseSystemOutEvents(childSystemOut?.body ?? null, timestamp ?? fallbackTimestamp, childWarnings);
198
+ return {
199
+ testName: child.attrs.name ?? `Test ${index + 1}`,
200
+ durationSeconds: parseDuration(child.attrs.time),
201
+ status: childStatus,
202
+ successRate: childStatus === "pass" ? 100 : 0,
203
+ failureOrError: childFailureOrError,
204
+ events: childEvents,
205
+ warnings: childWarnings,
206
+ };
207
+ })
208
+ : undefined;
209
+ return assembleRunReportModel({
210
+ suiteName: suite.attrs.name ?? null,
211
+ testName: childTests ? suite.attrs.name ?? suitesAttrs?.name ?? null : testcase?.attrs.name ?? null,
212
+ timestamp,
213
+ durationSeconds: detailDuration,
214
+ status,
215
+ testCases: tests,
216
+ successRate,
217
+ failureOrError,
218
+ events,
219
+ childTests,
220
+ warnings,
221
+ });
222
+ }
@@ -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, "&amp;")
10
+ .replace(/</g, "&lt;")
11
+ .replace(/>/g, "&gt;")
12
+ .replace(/"/g, "&quot;")
13
+ .replace(/'/g, "&#39;");
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">&#8249;</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">&#8250;</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">&times;</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
+ }