@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.
- package/README.md +18 -0
- package/build/index.js +16 -1
- package/build/src/browser/playwright/actions.js +17 -8
- package/build/src/cli/headless-execution-config.js +6 -0
- package/build/src/cli/headless-report.js +113 -0
- package/build/src/cli/headless-runner.js +40 -6
- package/build/src/commands/help.js +1 -0
- package/build/src/commands/run.js +2 -1
- package/build/src/core/execution-engine.js +125 -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 +146 -0
- package/build/src/core/report/run-report-model.js +143 -0
- package/build/src/core/secrets.js +116 -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 +17 -0
- package/build/src/prompts/design.js +4 -3
- package/build/src/prompts/execution.js +15 -1
- 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:
|
|
@@ -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 &&
|
|
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
|
+
}
|