@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
|
@@ -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
|
-
|
|
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 &&
|
|
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
|
+
}
|