@loadmill/droid-cua 2.17.0 → 2.19.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 +125 -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 +146 -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 +17 -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
@@ -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:
@@ -134,6 +185,19 @@ export class ExecutionEngine {
134
185
  if (shouldStop()) {
135
186
  return { stopped: true, sawExplicitScreenshotAction };
136
187
  }
188
+ if (context?.waitIfPaused) {
189
+ const pauseResult = await context.waitIfPaused({
190
+ boundary: "before_action",
191
+ refreshScreenOnResume: true,
192
+ stepContext,
193
+ });
194
+ if (pauseResult?.status === "stopped" || shouldStop()) {
195
+ return { stopped: true, sawExplicitScreenshotAction };
196
+ }
197
+ if (pauseResult?.refreshScreen) {
198
+ return { refreshModelState: true, sawExplicitScreenshotAction };
199
+ }
200
+ }
137
201
  if (action.type === "screenshot") {
138
202
  sawExplicitScreenshotAction = true;
139
203
  continue;
@@ -146,6 +210,10 @@ export class ExecutionEngine {
146
210
  eventId: actionEventId,
147
211
  captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION
148
212
  });
213
+ // Inline the green click marker as a data URL so self-contained reports show it.
214
+ // The Desktop recorder returns one from disk; for runs without a recorder (e.g. a
215
+ // plain CLI `--report`) render the same marker in-memory from the raw screenshot.
216
+ const annotatedDataUrl = await this.resolveAnnotatedDataUrl(annotatedScreenshot, currentScreenshot, action, context);
149
217
  await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
150
218
  ...context,
151
219
  shouldStop,
@@ -154,7 +222,7 @@ export class ExecutionEngine {
154
222
  eventId: actionEventId,
155
223
  captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
156
224
  ...(annotatedScreenshot?.screenshotPath ? { screenshotPath: annotatedScreenshot.screenshotPath } : {}),
157
- ...(context?.includeScreenshotDataUrl && annotatedScreenshot?.screenshotDataUrl ? { screenshotDataUrl: annotatedScreenshot.screenshotDataUrl } : {})
225
+ ...(context?.includeScreenshotDataUrl && annotatedDataUrl ? { screenshotDataUrl: annotatedDataUrl } : {})
158
226
  });
159
227
  if (trackAction) {
160
228
  trackAction(action);
@@ -168,6 +236,17 @@ export class ExecutionEngine {
168
236
  }
169
237
  return { stopped: false, sawExplicitScreenshotAction };
170
238
  }
239
+ async waitIfPaused(context = null, stepContext = null, boundary = "turn_boundary", refreshScreenOnResume = true) {
240
+ if (!context?.waitIfPaused) {
241
+ return { status: "running", refreshScreen: false };
242
+ }
243
+ const result = await context.waitIfPaused({
244
+ boundary,
245
+ refreshScreenOnResume,
246
+ stepContext,
247
+ });
248
+ return result ?? { status: "running", refreshScreen: false };
249
+ }
171
250
  async buildActionFollowUp(callId, pendingSafetyChecks, sawExplicitScreenshotAction, addOutput, eventMeta, context = null, stepContext = null) {
172
251
  const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
173
252
  emitDesktopDebug("device.screenshot", "device", {
@@ -303,11 +382,21 @@ export class ExecutionEngine {
303
382
  instructionIndex: stepContext?.instructionIndex,
304
383
  ...extra
305
384
  });
385
+ let refreshBeforeNextComputerAction = false;
306
386
  while (true) {
307
387
  // Check for interruption before processing next batch of actions
308
388
  if (shouldStop()) {
389
+ summary.stopped = true;
390
+ return finalize();
391
+ }
392
+ const pauseResult = await this.waitIfPaused(context, stepContext, "before_model_output", true);
393
+ if (pauseResult.status === "stopped" || shouldStop()) {
394
+ summary.stopped = true;
309
395
  return finalize();
310
396
  }
397
+ if (pauseResult.refreshScreen) {
398
+ refreshBeforeNextComputerAction = true;
399
+ }
311
400
  const items = response.output || [];
312
401
  const actionableItems = extractActionableOutputItems(items);
313
402
  const pendingSafetyChecks = collectPendingSafetyChecks(items);
@@ -330,8 +419,17 @@ export class ExecutionEngine {
330
419
  currentScreenshot = toolResult.currentScreenshot;
331
420
  }
332
421
  if (shouldStop()) {
422
+ summary.stopped = true;
333
423
  return finalize();
334
424
  }
425
+ const toolPauseResult = await this.waitIfPaused(context, stepContext, "after_tool_call", true);
426
+ if (toolPauseResult.status === "stopped" || shouldStop()) {
427
+ summary.stopped = true;
428
+ return finalize();
429
+ }
430
+ if (toolPauseResult.refreshScreen) {
431
+ refreshBeforeNextComputerAction = true;
432
+ }
335
433
  if (toolResult.kind === "fail") {
336
434
  summary.failed = true;
337
435
  summary.error = toolResult.error;
@@ -364,14 +462,39 @@ export class ExecutionEngine {
364
462
  continue;
365
463
  }
366
464
  const { call_id, actions } = actionableItem;
465
+ if (refreshBeforeNextComputerAction) {
466
+ const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, true, addOutput, eventMeta, context, stepContext);
467
+ currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
468
+ response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
469
+ newResponseId = response.id;
470
+ refreshBeforeNextComputerAction = false;
471
+ continue;
472
+ }
367
473
  const actionBatchResult = await this.executeActionBatch(actions, currentScreenshot, shouldStop, context, stepContext, trackAction);
368
474
  if (actionBatchResult.stopped) {
475
+ summary.stopped = true;
369
476
  return finalize();
370
477
  }
478
+ if (actionBatchResult.refreshModelState) {
479
+ const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, true, addOutput, eventMeta, context, stepContext);
480
+ currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
481
+ response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
482
+ newResponseId = response.id;
483
+ continue;
484
+ }
371
485
  const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, actionBatchResult.sawExplicitScreenshotAction, addOutput, eventMeta, context, stepContext);
372
486
  if (shouldStop()) {
487
+ summary.stopped = true;
488
+ return finalize();
489
+ }
490
+ const followUpPauseResult = await this.waitIfPaused(context, stepContext, "after_action_follow_up", true);
491
+ if (followUpPauseResult.status === "stopped" || shouldStop()) {
492
+ summary.stopped = true;
373
493
  return finalize();
374
494
  }
495
+ if (followUpPauseResult.refreshScreen) {
496
+ refreshBeforeNextComputerAction = true;
497
+ }
375
498
  currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
376
499
  response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
377
500
  newResponseId = response.id;
@@ -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, buildTestDataContextSection, } 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
+ }