@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.
Files changed (39) hide show
  1. package/README.md +18 -0
  2. package/build/index.js +88 -1
  3. package/build/src/browser/playwright/actions.js +17 -8
  4. package/build/src/cli/headless-execution-config.js +21 -0
  5. package/build/src/cli/headless-report.js +113 -0
  6. package/build/src/cli/headless-runner.js +57 -11
  7. package/build/src/cli/scenario-planner.js +1 -0
  8. package/build/src/commands/help.js +4 -0
  9. package/build/src/commands/run.js +2 -1
  10. package/build/src/core/execution-engine.js +57 -2
  11. package/build/src/core/prompts.js +1 -1
  12. package/build/src/core/report/generate-html-report.js +88 -0
  13. package/build/src/core/report/parse-run-report.js +222 -0
  14. package/build/src/core/report/render-html-report.js +317 -0
  15. package/build/src/core/report/report-file-name.js +31 -0
  16. package/build/src/core/report/run-log-model.js +144 -0
  17. package/build/src/core/report/run-report-model.js +143 -0
  18. package/build/src/core/scenario-planner.js +85 -0
  19. package/build/src/core/secrets.js +116 -0
  20. package/build/src/core/test-data-import.js +62 -0
  21. package/build/src/core/test-data.js +103 -0
  22. package/build/src/core/tool-handlers/close-current-tab.js +3 -3
  23. package/build/src/core/tool-handlers/navigate.js +3 -3
  24. package/build/src/core/tool-handlers/preview-click.js +6 -6
  25. package/build/src/core/tool-handlers/type-sensitive-text.js +62 -0
  26. package/build/src/core/tool-handlers/utils.js +3 -3
  27. package/build/src/device/android/actions.js +13 -8
  28. package/build/src/device/cloud/actions.js +17 -8
  29. package/build/src/device/ios/actions.js +17 -8
  30. package/build/src/device/openai.js +17 -0
  31. package/build/src/device/sensitive-action.js +61 -0
  32. package/build/src/modes/design-mode-ink.js +3 -1
  33. package/build/src/modes/design-mode.js +3 -1
  34. package/build/src/modes/execution-mode.js +1 -0
  35. package/build/src/prompts/design.js +4 -3
  36. package/build/src/prompts/execution.js +27 -1
  37. package/build/src/prompts/scenario-planner.js +48 -0
  38. package/build/src/prompts/test-data-import.js +49 -0
  39. package/package.json +1 -1
@@ -5,9 +5,11 @@ import { handleModelAction } from "../device/actions.js";
5
5
  import { sendCUARequest } from "../device/openai.js";
6
6
  import { executeLoadmillInstruction } from "../device/loadmill.js";
7
7
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
8
+ import { createAnnotatedScreenshotFromBase64 } from "../utils/run-screenshot-recorder.js";
8
9
  import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
9
10
  import { printCliOutput } from "../utils/console-output.js";
10
11
  import { CAPTURE_SOURCES } from "./capture-sources.js";
12
+ import { normalizeSecretKeys } from "./secrets.js";
11
13
  import { ScreenshotStore } from "./screenshot-store.js";
12
14
  import { handlePreviewClick } from "./tool-handlers/preview-click.js";
13
15
  import { handleNavigateToolCall } from "./tool-handlers/navigate.js";
@@ -15,6 +17,7 @@ import { handleCloseCurrentTabToolCall } from "./tool-handlers/close-current-tab
15
17
  import { handleAssertionToolCall } from "./tool-handlers/assert.js";
16
18
  import { handleLoadmillToolCall } from "./tool-handlers/loadmill.js";
17
19
  import { handleTestDesignCompleteToolCall } from "./tool-handlers/test-design-complete.js";
20
+ import { handleTypeSensitiveTextToolCall } from "./tool-handlers/type-sensitive-text.js";
18
21
  import { buildDebugContext, buildTextToolOutputItem, } from "./tool-handlers/utils.js";
19
22
  function extractActionableOutputItems(items) {
20
23
  const entries = [];
@@ -77,11 +80,33 @@ export class ExecutionEngine {
77
80
  this.closeCurrentTab = options.closeCurrentTab || closeCurrentBrowserTab;
78
81
  this.executeLoadmillInstruction = options.executeLoadmillInstruction || executeLoadmillInstruction;
79
82
  this.toolCapabilities = options.toolCapabilities || {};
83
+ this.secretsByKey = options.secretsByKey && typeof options.secretsByKey === "object" ? options.secretsByKey : {};
80
84
  this.baseSystemPrompt = options.baseSystemPrompt || session?.systemPrompt || null;
81
85
  this.isHeadlessMode = options.isHeadlessMode === true;
82
86
  }
87
+ hasSecrets() {
88
+ return this.getAvailableSecretKeys().length > 0;
89
+ }
90
+ getAvailableSecretKeys() {
91
+ return normalizeSecretKeys(Object.keys(this.secretsByKey));
92
+ }
83
93
  async recordScreenshot(screenshotBase64, metadata = {}) {
84
- return await this.screenshotStore.saveScreenshot(screenshotBase64, metadata);
94
+ const saved = await this.screenshotStore.saveScreenshot(screenshotBase64, metadata);
95
+ if (saved) {
96
+ return saved;
97
+ }
98
+ // No recorder/persistence (e.g. a plain CLI `--report` run): still hold onto the raw
99
+ // base64 so downstream click-annotation can render the marker in-memory for the report.
100
+ if (typeof screenshotBase64 !== "string" || !screenshotBase64) {
101
+ return null;
102
+ }
103
+ return {
104
+ base64: screenshotBase64,
105
+ artifactPaths: [],
106
+ annotationSourcePaths: [],
107
+ screenshotPath: null,
108
+ screenshotDataUrl: null,
109
+ };
85
110
  }
86
111
  async annotateScreenshot(currentScreenshot, action, metadata = {}) {
87
112
  if (!(action?.type === "click" || action?.type === "double_click")) {
@@ -89,6 +114,30 @@ export class ExecutionEngine {
89
114
  }
90
115
  return await this.screenshotStore.annotateScreenshot(currentScreenshot, { x: action.x, y: action.y, style: "click" }, { ...metadata, actionType: action.type });
91
116
  }
117
+ // Resolves the click-annotation data URL for an action event when a self-contained
118
+ // report is being produced (`includeScreenshotDataUrl`). Prefers the recorder's own
119
+ // annotated data URL (Desktop); otherwise renders the green "click" marker in-memory
120
+ // from the raw screenshot so reports work even without a disk recorder.
121
+ async resolveAnnotatedDataUrl(annotatedScreenshot, currentScreenshot, action, context) {
122
+ if (!context?.includeScreenshotDataUrl) {
123
+ return null;
124
+ }
125
+ if (annotatedScreenshot?.screenshotDataUrl) {
126
+ return annotatedScreenshot.screenshotDataUrl;
127
+ }
128
+ if (!(action?.type === "click" || action?.type === "double_click")) {
129
+ return null;
130
+ }
131
+ if (typeof currentScreenshot?.base64 !== "string" || !currentScreenshot.base64) {
132
+ return null;
133
+ }
134
+ const annotated = await createAnnotatedScreenshotFromBase64(currentScreenshot.base64, {
135
+ x: action.x,
136
+ y: action.y,
137
+ style: "click",
138
+ });
139
+ return annotated?.screenshotBase64 ? `data:image/png;base64,${annotated.screenshotBase64}` : null;
140
+ }
92
141
  /**
93
142
  * Dispatches a function-call tool to its handler. Each handler returns a
94
143
  * discriminated outcome that runFullTurn interprets below. Continue and
@@ -108,6 +157,8 @@ export class ExecutionEngine {
108
157
  return await handleCloseCurrentTabToolCall(handlerArgs);
109
158
  case "loadmill":
110
159
  return await handleLoadmillToolCall(handlerArgs);
160
+ case "type_sensitive_text":
161
+ return await handleTypeSensitiveTextToolCall(handlerArgs);
111
162
  case "test_design_complete":
112
163
  return await handleTestDesignCompleteToolCall(handlerArgs);
113
164
  default:
@@ -146,6 +197,10 @@ export class ExecutionEngine {
146
197
  eventId: actionEventId,
147
198
  captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION
148
199
  });
200
+ // Inline the green click marker as a data URL so self-contained reports show it.
201
+ // The Desktop recorder returns one from disk; for runs without a recorder (e.g. a
202
+ // plain CLI `--report`) render the same marker in-memory from the raw screenshot.
203
+ const annotatedDataUrl = await this.resolveAnnotatedDataUrl(annotatedScreenshot, currentScreenshot, action, context);
149
204
  await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
150
205
  ...context,
151
206
  shouldStop,
@@ -154,7 +209,7 @@ export class ExecutionEngine {
154
209
  eventId: actionEventId,
155
210
  captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
156
211
  ...(annotatedScreenshot?.screenshotPath ? { screenshotPath: annotatedScreenshot.screenshotPath } : {}),
157
- ...(context?.includeScreenshotDataUrl && annotatedScreenshot?.screenshotDataUrl ? { screenshotDataUrl: annotatedScreenshot.screenshotDataUrl } : {})
212
+ ...(context?.includeScreenshotDataUrl && annotatedDataUrl ? { screenshotDataUrl: annotatedDataUrl } : {})
158
213
  });
159
214
  if (trackAction) {
160
215
  trackAction(action);
@@ -1,3 +1,3 @@
1
1
  export { buildBaseSystemPrompt } from "../prompts/base.js";
2
2
  export { buildDesignModePrompt, buildDesignRecoveryPrompt, buildDesignScriptGenerationPrompt, } from "../prompts/design.js";
3
- export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, } from "../prompts/execution.js";
3
+ export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, buildSecretKeysContextSection, buildTestDataContextSection, } from "../prompts/execution.js";
@@ -0,0 +1,88 @@
1
+ // File/glue layer for the HTML report: reads a stored JUnit run-result XML, inlines
2
+ // its sibling screenshot PNGs as base64 data URLs, and renders a self-contained HTML
3
+ // document. Used by both the CLI (`--report`) and the Desktop App ("Share" button).
4
+ import { readFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { parseRunReport } from "./parse-run-report.js";
7
+ import { renderHtmlReport } from "./render-html-report.js";
8
+ const MIME_BY_EXT = {
9
+ ".png": "image/png",
10
+ ".jpg": "image/jpeg",
11
+ ".jpeg": "image/jpeg",
12
+ ".webp": "image/webp",
13
+ ".gif": "image/gif",
14
+ };
15
+ function mimeForPath(filePath) {
16
+ return MIME_BY_EXT[path.extname(filePath).toLowerCase()] ?? "image/png";
17
+ }
18
+ // Resolves a screenshotPath (relative to the results directory) to an absolute path,
19
+ // guarding against escaping the results directory via traversal. Returns null if the
20
+ // resolved path would fall outside `resultsDir`.
21
+ function resolveScreenshotPath(resultsDir, screenshotPath) {
22
+ if (typeof screenshotPath !== "string" || screenshotPath.length === 0)
23
+ return null;
24
+ const resolved = path.resolve(resultsDir, screenshotPath);
25
+ const base = path.resolve(resultsDir);
26
+ if (resolved !== base && !resolved.startsWith(base + path.sep))
27
+ return null;
28
+ return resolved;
29
+ }
30
+ // Reads an image file and returns a data URL, or null if it cannot be read.
31
+ async function toDataUrl(filePath) {
32
+ try {
33
+ const buffer = await readFile(filePath);
34
+ return `data:${mimeForPath(filePath)};base64,${buffer.toString("base64")}`;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ // Walks every event (top-level and child tests) and replaces payload.screenshotPath
41
+ // with an inlined payload.screenshotDataUrl read from disk. Missing files are skipped.
42
+ async function inlineScreenshots(model, resultsDir) {
43
+ const allEventLists = [model.events ?? []];
44
+ for (const child of model.childTests ?? []) {
45
+ allEventLists.push(child.events ?? []);
46
+ }
47
+ for (const events of allEventLists) {
48
+ for (const event of events) {
49
+ const screenshotPath = event.payload?.screenshotPath;
50
+ if (typeof screenshotPath !== "string" || screenshotPath.length === 0)
51
+ continue;
52
+ if (typeof event.payload?.screenshotDataUrl === "string")
53
+ continue; // already inlined
54
+ const absolute = resolveScreenshotPath(resultsDir, screenshotPath);
55
+ if (!absolute)
56
+ continue;
57
+ const dataUrl = await toDataUrl(absolute);
58
+ if (dataUrl) {
59
+ event.payload = { ...event.payload, screenshotDataUrl: dataUrl };
60
+ }
61
+ }
62
+ }
63
+ return model;
64
+ }
65
+ // Builds the HTML report from an in-memory model. Screenshots are inlined from
66
+ // `resultsDir` (the directory the relative screenshotPath values are based on).
67
+ export async function generateHtmlReportFromModel(model, { resultsDir, title } = {}) {
68
+ if (resultsDir) {
69
+ await inlineScreenshots(model, resultsDir);
70
+ }
71
+ return renderHtmlReport(model, { title });
72
+ }
73
+ // Reads a stored run-result XML file and produces both the self-contained HTML report
74
+ // and the parsed model (so callers can derive a filename from testName/timestamp without
75
+ // re-reading the file). Screenshots are read from the sibling directory the relative
76
+ // screenshotPath values reference (which is the XML file's own directory).
77
+ export async function generateReportFromXmlFile(xmlPath, { title } = {}) {
78
+ const xml = await readFile(xmlPath, "utf-8");
79
+ const model = parseRunReport(xml);
80
+ const resultsDir = path.dirname(path.resolve(xmlPath));
81
+ const html = await generateHtmlReportFromModel(model, { resultsDir, title });
82
+ return { html, model };
83
+ }
84
+ // Convenience wrapper returning just the HTML for callers that don't need the model.
85
+ export async function generateHtmlReportFromXmlFile(xmlPath, options = {}) {
86
+ const { html } = await generateReportFromXmlFile(xmlPath, options);
87
+ return html;
88
+ }
@@ -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
+ }