@allurereport/reader 3.21.0 → 3.22.1
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 +19 -0
- package/dist/allure1/index.js +1 -1
- package/dist/file-attachments.d.ts +1 -0
- package/dist/file-attachments.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/junitxml/index.js +4 -27
- package/dist/openTestReporting/attachments.d.ts +5 -0
- package/dist/openTestReporting/attachments.js +72 -0
- package/dist/openTestReporting/index.d.ts +2 -0
- package/dist/openTestReporting/index.js +92 -0
- package/dist/openTestReporting/metadata.d.ts +13 -0
- package/dist/openTestReporting/metadata.js +121 -0
- package/dist/openTestReporting/model.d.ts +14 -0
- package/dist/openTestReporting/model.js +116 -0
- package/dist/openTestReporting/xml.d.ts +12 -0
- package/dist/openTestReporting/xml.js +56 -0
- package/package.json +12 -10
package/README.md
CHANGED
|
@@ -14,6 +14,25 @@
|
|
|
14
14
|
|
|
15
15
|
Set of Allure Reader implementations which allow to read different files formats as Allure Results files.
|
|
16
16
|
|
|
17
|
+
### Open Test Reporting
|
|
18
|
+
|
|
19
|
+
The `openTestReporting` reader supports the event-based and hierarchical XML formats from
|
|
20
|
+
[Open Test Reporting](https://github.com/ota4j-team/open-test-reporting), using schema version **0.2.0** only.
|
|
21
|
+
It is included among Allure's default readers, so no additional configuration is required.
|
|
22
|
+
|
|
23
|
+
Reports are recognized by their XML namespaces, regardless of the producer, namespace prefixes, or `.xml` filename.
|
|
24
|
+
Ancestor names provide suite grouping. When JUnit's optional type metadata is present, `TEST` and
|
|
25
|
+
`CONTAINER_AND_TEST` nodes become test results, while `CONTAINER` nodes provide structure only. Test/container
|
|
26
|
+
nodes also retain their child tests. Missing or unknown types use leaf-node inference, so other producers do not
|
|
27
|
+
need JUnit metadata. Failed containers are reported as global errors, preserving their reasons and Java traces.
|
|
28
|
+
The reader imports statuses, timing, reasons, tags, source information, infrastructure metadata, and the standard
|
|
29
|
+
Java source and throwable extensions when present. Other unknown extensions are ignored.
|
|
30
|
+
|
|
31
|
+
Core data and output attachments are preserved as JSON and text. File attachments are resolved relative to the
|
|
32
|
+
report file's directory; absolute paths are accepted only within that directory. Symbolic links and paths outside
|
|
33
|
+
the report directory are rejected. Missing or unsafe attachments produce a warning without preventing test import.
|
|
34
|
+
Local file attachments require a filesystem-backed report, not an in-memory XML buffer.
|
|
35
|
+
|
|
17
36
|
## Install
|
|
18
37
|
|
|
19
38
|
Use your favorite package manager to install the package:
|
package/dist/allure1/index.js
CHANGED
|
@@ -65,7 +65,7 @@ const parseRootElement = async (visitor, xml) => {
|
|
|
65
65
|
return await parseTestSuite(visitor, testSuite);
|
|
66
66
|
};
|
|
67
67
|
const parseTestSuite = async (visitor, testSuite) => {
|
|
68
|
-
const { name: testSuiteName, title: testSuiteTitle, description: descriptionElement, "test-cases": testCases, labels: labelsElement, } = testSuite;
|
|
68
|
+
const { "name": testSuiteName, "title": testSuiteTitle, "description": descriptionElement, "test-cases": testCases, "labels": labelsElement, } = testSuite;
|
|
69
69
|
if (!isStringAnyRecord(testCases)) {
|
|
70
70
|
return false;
|
|
71
71
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const readAttachmentFile: (reportDir: string, path: string) => Promise<Buffer>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
export const readAttachmentFile = async (reportDir, path) => {
|
|
5
|
+
const root = resolve(reportDir);
|
|
6
|
+
const attachmentPath = resolve(root, path);
|
|
7
|
+
const relativePath = relative(root, attachmentPath);
|
|
8
|
+
if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
|
9
|
+
throw new Error("Attachment path must be inside the report directory");
|
|
10
|
+
}
|
|
11
|
+
const parts = relativePath.split(sep);
|
|
12
|
+
let currentPath = root;
|
|
13
|
+
for (const [index, part] of parts.entries()) {
|
|
14
|
+
currentPath = join(currentPath, part);
|
|
15
|
+
const stats = await lstat(currentPath);
|
|
16
|
+
if (stats.isSymbolicLink()) {
|
|
17
|
+
throw new Error("Symbolic links are not allowed in attachment paths");
|
|
18
|
+
}
|
|
19
|
+
if (index === parts.length - 1 && !stats.isFile()) {
|
|
20
|
+
throw new Error("Attachment path must refer to a regular file");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return readFile(attachmentPath, { flag: constants.O_RDONLY | constants.O_NOFOLLOW });
|
|
24
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./allure2/index.js";
|
|
|
3
3
|
export * from "./cucumberjson/index.js";
|
|
4
4
|
export * from "./cucumbermessages/index.js";
|
|
5
5
|
export * from "./junitxml/index.js";
|
|
6
|
+
export * from "./openTestReporting/index.js";
|
|
6
7
|
export * from "./xcresult/index.js";
|
|
7
8
|
export * from "./attachments/index.js";
|
|
8
9
|
export type * from "./model.js";
|
package/dist/index.js
CHANGED
|
@@ -3,5 +3,6 @@ export * from "./allure2/index.js";
|
|
|
3
3
|
export * from "./cucumberjson/index.js";
|
|
4
4
|
export * from "./cucumbermessages/index.js";
|
|
5
5
|
export * from "./junitxml/index.js";
|
|
6
|
+
export * from "./openTestReporting/index.js";
|
|
6
7
|
export * from "./xcresult/index.js";
|
|
7
8
|
export * from "./attachments/index.js";
|
package/dist/junitxml/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import console from "node:console";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import { lstat, readFile } from "node:fs/promises";
|
|
5
|
-
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, extname } from "node:path";
|
|
6
4
|
import { BufferResultFile, PathResultFile } from "@allurereport/reader-api";
|
|
7
5
|
import { XMLParser } from "fast-xml-parser";
|
|
6
|
+
import { readAttachmentFile } from "../file-attachments.js";
|
|
8
7
|
import { ensureString } from "../utils.js";
|
|
9
8
|
import { isEmptyElement, isStringAnyRecord, isStringAnyRecordArray } from "../xml-utils.js";
|
|
10
9
|
const MS_IN_S = 1000;
|
|
@@ -91,7 +90,7 @@ const parseTestSuite = async (visitor, testSuite, isAggregated, reportDir) => {
|
|
|
91
90
|
}
|
|
92
91
|
};
|
|
93
92
|
const parseTestCase = async (visitor, { name: suiteName, suitePackage }, testCase, isAggregated, reportDir) => {
|
|
94
|
-
const { name: nameAttribute, failure, error, skipped, classname: classNameAttribute, time, "system-out": systemOutAttribute, "system-err": systemErrAttribute, } = testCase;
|
|
93
|
+
const { "name": nameAttribute, failure, error, skipped, "classname": classNameAttribute, time, "system-out": systemOutAttribute, "system-err": systemErrAttribute, } = testCase;
|
|
95
94
|
const name = ensureString(nameAttribute);
|
|
96
95
|
const className = ensureString(classNameAttribute);
|
|
97
96
|
const systemOut = ensureString(systemOutAttribute);
|
|
@@ -119,8 +118,7 @@ const parseAttachments = async (visitor, reportDir, systemOut, systemErr) => {
|
|
|
119
118
|
}
|
|
120
119
|
for (const [, path] of systemOut?.matchAll(/\[\[ATTACHMENT\|([^\r\n]*?)\]\]/g) ?? []) {
|
|
121
120
|
try {
|
|
122
|
-
const
|
|
123
|
-
const content = await readFile(attachmentPath, { flag: constants.O_RDONLY | constants.O_NOFOLLOW });
|
|
121
|
+
const content = await readAttachmentFile(reportDir, path);
|
|
124
122
|
const file = new BufferResultFile(content, `${randomUUID()}${extname(path)}`);
|
|
125
123
|
await visitor.visitAttachmentFile(file, { readerId });
|
|
126
124
|
attachments.push({
|
|
@@ -136,27 +134,6 @@ const parseAttachments = async (visitor, reportDir, systemOut, systemErr) => {
|
|
|
136
134
|
}
|
|
137
135
|
return attachments;
|
|
138
136
|
};
|
|
139
|
-
const resolveAttachmentPath = async (reportDir, path) => {
|
|
140
|
-
const root = resolve(reportDir);
|
|
141
|
-
const attachmentPath = resolve(root, path);
|
|
142
|
-
const relativePath = relative(root, attachmentPath);
|
|
143
|
-
if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
|
144
|
-
throw new Error("Attachment path must be inside the report directory");
|
|
145
|
-
}
|
|
146
|
-
const parts = relativePath.split(sep);
|
|
147
|
-
let currentPath = root;
|
|
148
|
-
for (const [index, part] of parts.entries()) {
|
|
149
|
-
currentPath = join(currentPath, part);
|
|
150
|
-
const stats = await lstat(currentPath);
|
|
151
|
-
if (stats.isSymbolicLink()) {
|
|
152
|
-
throw new Error("Symbolic links are not allowed in attachment paths");
|
|
153
|
-
}
|
|
154
|
-
if (index === parts.length - 1 && !stats.isFile()) {
|
|
155
|
-
throw new Error("Attachment path must refer to a regular file");
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return attachmentPath;
|
|
159
|
-
};
|
|
160
137
|
const visitPlainTextAttachment = async (visitor, name, content) => {
|
|
161
138
|
const fileName = randomUUID();
|
|
162
139
|
await visitor.visitAttachmentFile(new BufferResultFile(Buffer.from(content), fileName), { readerId });
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type RawTestAttachment, type ResultsVisitor } from "@allurereport/reader-api";
|
|
2
|
+
import { type ReportNode } from "./model.js";
|
|
3
|
+
import { type XmlElement } from "./xml.js";
|
|
4
|
+
export declare const attachmentElements: (path: ReportNode[]) => XmlElement[];
|
|
5
|
+
export declare const visitAttachments: (visitor: ResultsVisitor, elementsToVisit: XmlElement[], reportDir: string | undefined) => Promise<RawTestAttachment[]>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
3
|
+
import { BufferResultFile, isLiteral } from "@allurereport/reader-api";
|
|
4
|
+
import { readAttachmentFile } from "../file-attachments.js";
|
|
5
|
+
import { parseTime } from "./model.js";
|
|
6
|
+
import { CORE, elements } from "./xml.js";
|
|
7
|
+
export const attachmentElements = (path) => path
|
|
8
|
+
.flatMap((node) => node.info
|
|
9
|
+
.filter((info) => info.namespace === CORE && info.name === "attachments")
|
|
10
|
+
.flatMap((info) => info.children))
|
|
11
|
+
.filter((element) => element.namespace === CORE && isLiteral(element.name, ["data", "output", "file"]));
|
|
12
|
+
export const visitAttachments = async (visitor, elementsToVisit, reportDir) => {
|
|
13
|
+
const attachments = [];
|
|
14
|
+
for (const element of elementsToVisit) {
|
|
15
|
+
let content;
|
|
16
|
+
let name;
|
|
17
|
+
let extension;
|
|
18
|
+
let contentType;
|
|
19
|
+
if (element.name === "file") {
|
|
20
|
+
const path = element.attributes.path;
|
|
21
|
+
if (!path) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
if (reportDir === undefined) {
|
|
26
|
+
throw new Error("Local attachments require a report file path");
|
|
27
|
+
}
|
|
28
|
+
content = await readAttachmentFile(reportDir, path);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
console.warn(`Unable to read Open Test Reporting attachment "${path}":`, error);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
name = basename(path);
|
|
35
|
+
extension = extname(path);
|
|
36
|
+
contentType = element.attributes.mediaType;
|
|
37
|
+
}
|
|
38
|
+
else if (element.name === "data") {
|
|
39
|
+
content = Buffer.from(JSON.stringify(elements(element, "entry").flatMap((entry) => {
|
|
40
|
+
const key = entry.attributes.key;
|
|
41
|
+
return key === undefined ? [] : [{ key, value: entry.text }];
|
|
42
|
+
}), null, 2));
|
|
43
|
+
name = "Data";
|
|
44
|
+
extension = ".json";
|
|
45
|
+
contentType = "application/json";
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const source = element.attributes.source;
|
|
49
|
+
if (!source) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
content = Buffer.from(element.text);
|
|
53
|
+
name = source;
|
|
54
|
+
extension = ".txt";
|
|
55
|
+
contentType = "text/plain";
|
|
56
|
+
}
|
|
57
|
+
const file = new BufferResultFile(content, `${randomUUID()}${extension}`);
|
|
58
|
+
if (contentType !== undefined) {
|
|
59
|
+
file.contentType = contentType;
|
|
60
|
+
}
|
|
61
|
+
await visitor.visitAttachmentFile(file, { readerId: "open-test-reporting" });
|
|
62
|
+
attachments.push({
|
|
63
|
+
type: "attachment",
|
|
64
|
+
name,
|
|
65
|
+
originalFileName: file.getOriginalFileName(),
|
|
66
|
+
contentType: file.getContentType(),
|
|
67
|
+
contentLength: file.getContentLength(),
|
|
68
|
+
start: parseTime(element.attributes.time),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return attachments;
|
|
72
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { PathResultFile, isLiteral, } from "@allurereport/reader-api";
|
|
3
|
+
import { attachmentElements, visitAttachments } from "./attachments.js";
|
|
4
|
+
import { infrastructureLabels, nodeMetadata, nodeType, resultDetails } from "./metadata.js";
|
|
5
|
+
import { nodeStatus, reportNodes, resultElement } from "./model.js";
|
|
6
|
+
import { parseXml } from "./xml.js";
|
|
7
|
+
const readerId = "open-test-reporting";
|
|
8
|
+
const suiteLabels = (ancestors) => {
|
|
9
|
+
const names = ancestors.map((node) => node.name);
|
|
10
|
+
if (names.length === 0) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
if (names.length === 1) {
|
|
14
|
+
return [{ name: "suite", value: names[0] }];
|
|
15
|
+
}
|
|
16
|
+
const labels = [
|
|
17
|
+
{ name: "parentSuite", value: names[0] },
|
|
18
|
+
{ name: "suite", value: names[1] },
|
|
19
|
+
];
|
|
20
|
+
if (names.length > 2) {
|
|
21
|
+
labels.push({ name: "subSuite", value: names.slice(2).join(" > ") });
|
|
22
|
+
}
|
|
23
|
+
return labels;
|
|
24
|
+
};
|
|
25
|
+
const collectResults = (nodes, infrastructure) => {
|
|
26
|
+
const results = [];
|
|
27
|
+
const errors = [];
|
|
28
|
+
const visit = (node, ancestors) => {
|
|
29
|
+
const path = [...ancestors, node];
|
|
30
|
+
const names = path.map((entry) => entry.name);
|
|
31
|
+
const type = nodeType(node);
|
|
32
|
+
if (type === "CONTAINER") {
|
|
33
|
+
const status = nodeStatus(node);
|
|
34
|
+
if (isLiteral(status, ["failed", "broken"])) {
|
|
35
|
+
const details = resultDetails(resultElement(node));
|
|
36
|
+
errors.push({ ...details, message: `${names.join(" > ")}: ${details.message ?? status}` });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else if (isLiteral(type, ["TEST", "CONTAINER_AND_TEST"]) || node.children.length === 0) {
|
|
40
|
+
const metadata = nodeMetadata(path);
|
|
41
|
+
results.push({
|
|
42
|
+
result: {
|
|
43
|
+
name: node.name,
|
|
44
|
+
fullName: JSON.stringify(metadata.sourceIdentity === undefined ? names : [metadata.sourceIdentity, ...names]),
|
|
45
|
+
titlePath: ancestors.map((entry) => entry.name),
|
|
46
|
+
labels: [...suiteLabels(ancestors), ...infrastructure, ...metadata.labels],
|
|
47
|
+
status: nodeStatus(node),
|
|
48
|
+
...resultDetails(resultElement(node)),
|
|
49
|
+
start: node.start,
|
|
50
|
+
stop: node.stop,
|
|
51
|
+
duration: node.duration,
|
|
52
|
+
},
|
|
53
|
+
attachments: attachmentElements(path),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
node.children.forEach((child) => visit(child, path));
|
|
57
|
+
};
|
|
58
|
+
nodes.forEach((node) => visit(node, []));
|
|
59
|
+
return { results, errors };
|
|
60
|
+
};
|
|
61
|
+
class OpenTestReportingReader {
|
|
62
|
+
readerId() {
|
|
63
|
+
return readerId;
|
|
64
|
+
}
|
|
65
|
+
matches(data) {
|
|
66
|
+
return data.getOriginalFileName().endsWith(".xml");
|
|
67
|
+
}
|
|
68
|
+
async read(visitor, data) {
|
|
69
|
+
try {
|
|
70
|
+
const content = await data.asUtf8String();
|
|
71
|
+
const root = content ? parseXml(content) : undefined;
|
|
72
|
+
const nodes = root ? reportNodes(root) : undefined;
|
|
73
|
+
if (!nodes) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const { results, errors } = collectResults(nodes, infrastructureLabels(root));
|
|
77
|
+
const reportDir = data instanceof PathResultFile ? dirname(data.path) : undefined;
|
|
78
|
+
if (errors.length) {
|
|
79
|
+
await visitor.visitGlobals({ errors, attachments: [] }, { readerId });
|
|
80
|
+
}
|
|
81
|
+
for (const { result, attachments } of results) {
|
|
82
|
+
result.steps = await visitAttachments(visitor, attachments, reportDir);
|
|
83
|
+
await visitor.visitTestResult(result, { readerId });
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export const openTestReporting = new OpenTestReportingReader();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type RawTestLabel } from "@allurereport/reader-api";
|
|
2
|
+
import type { ReportNode } from "./model.js";
|
|
3
|
+
import { type XmlElement } from "./xml.js";
|
|
4
|
+
export declare const nodeType: (node: ReportNode) => string | undefined;
|
|
5
|
+
export declare const nodeMetadata: (path: ReportNode[]) => {
|
|
6
|
+
sourceIdentity: string | undefined;
|
|
7
|
+
labels: RawTestLabel[];
|
|
8
|
+
};
|
|
9
|
+
export declare const infrastructureLabels: (root: XmlElement) => RawTestLabel[];
|
|
10
|
+
export declare const resultDetails: (result: XmlElement | undefined) => {
|
|
11
|
+
message: string | undefined;
|
|
12
|
+
trace: string | undefined;
|
|
13
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { ensureLiteral, isLiteral } from "@allurereport/reader-api";
|
|
2
|
+
import { CORE, elements } from "./xml.js";
|
|
3
|
+
const JAVA = "https://schemas.opentest4j.org/reporting/java/0.2.0";
|
|
4
|
+
const JUNIT = "https://schemas.junit.org/open-test-reporting";
|
|
5
|
+
export const nodeType = (node) => node.info
|
|
6
|
+
.filter((info) => info.namespace === CORE && info.name === "metadata")
|
|
7
|
+
.flatMap((metadata) => elements(metadata, "type", JUNIT))
|
|
8
|
+
.at(-1)
|
|
9
|
+
?.text.trim();
|
|
10
|
+
const sourceDetails = (source) => {
|
|
11
|
+
const labels = [];
|
|
12
|
+
let identity;
|
|
13
|
+
const attributes = source.attributes;
|
|
14
|
+
if (source.namespace === CORE && isLiteral(source.name, ["directorySource", "fileSource"])) {
|
|
15
|
+
identity = attributes.path;
|
|
16
|
+
if (identity !== undefined) {
|
|
17
|
+
labels.push({ name: "path", value: identity });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
else if (source.namespace === JAVA) {
|
|
21
|
+
if (isLiteral(source.name, ["classSource", "methodSource"])) {
|
|
22
|
+
const className = attributes.className;
|
|
23
|
+
identity = className;
|
|
24
|
+
if (className !== undefined) {
|
|
25
|
+
labels.push({ name: "testClass", value: className });
|
|
26
|
+
const dot = className.lastIndexOf(".");
|
|
27
|
+
if (dot !== -1) {
|
|
28
|
+
labels.push({ name: "package", value: className.slice(0, dot) });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (source.name === "methodSource" && attributes.methodName !== undefined) {
|
|
32
|
+
labels.push({ name: "testMethod", value: attributes.methodName });
|
|
33
|
+
identity = `${className === undefined ? "" : `${className}.`}${attributes.methodName}(${attributes.methodParameterTypes ?? ""})`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (source.name === "classpathResourceSource") {
|
|
37
|
+
identity = attributes.resourceName;
|
|
38
|
+
if (identity !== undefined) {
|
|
39
|
+
labels.push({ name: "path", value: identity });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else if (source.name === "packageSource") {
|
|
43
|
+
identity = attributes.name;
|
|
44
|
+
if (identity !== undefined) {
|
|
45
|
+
labels.push({ name: "package", value: identity });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (identity !== undefined) {
|
|
50
|
+
const position = elements(source, "filePosition", source.namespace)[0];
|
|
51
|
+
for (const name of ["line", "column"]) {
|
|
52
|
+
const value = position?.attributes[name];
|
|
53
|
+
if (value !== undefined) {
|
|
54
|
+
labels.push({ name, value });
|
|
55
|
+
identity += `:${value}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { identity, labels };
|
|
60
|
+
};
|
|
61
|
+
export const nodeMetadata = (path) => {
|
|
62
|
+
const labels = [];
|
|
63
|
+
let sourceIdentity;
|
|
64
|
+
for (const node of path) {
|
|
65
|
+
for (const info of node.info) {
|
|
66
|
+
if (info.namespace !== CORE) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (info.name === "metadata") {
|
|
70
|
+
for (const tags of elements(info, "tags")) {
|
|
71
|
+
labels.push(...elements(tags, "tag").map((tag) => ({ name: "tag", value: tag.text })));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (info.name === "sources") {
|
|
75
|
+
for (const source of info.children) {
|
|
76
|
+
const details = sourceDetails(source);
|
|
77
|
+
labels.push(...details.labels);
|
|
78
|
+
sourceIdentity = details.identity ?? sourceIdentity;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
return {
|
|
85
|
+
sourceIdentity,
|
|
86
|
+
labels: labels.filter((label) => {
|
|
87
|
+
const key = JSON.stringify([label.name, label.value]);
|
|
88
|
+
if (seen.has(key)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
seen.add(key);
|
|
92
|
+
return true;
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
export const infrastructureLabels = (root) => {
|
|
97
|
+
const coreNames = new Map([
|
|
98
|
+
["hostName", "host"],
|
|
99
|
+
["userName", "user"],
|
|
100
|
+
["operatingSystem", "os"],
|
|
101
|
+
["cpuCores", "cpuCores"],
|
|
102
|
+
]);
|
|
103
|
+
return elements(root, "infrastructure").flatMap((infrastructure) => infrastructure.children.flatMap((field) => {
|
|
104
|
+
const name = field.namespace === CORE
|
|
105
|
+
? coreNames.get(field.name)
|
|
106
|
+
: field.namespace === JAVA
|
|
107
|
+
? ensureLiteral(field.name, ["javaVersion", "fileEncoding", "heapSize"])
|
|
108
|
+
: undefined;
|
|
109
|
+
if (!name) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
const value = field.name === "heapSize" ? field.attributes.max : field.text;
|
|
113
|
+
return value === undefined ? [] : [{ name, value }];
|
|
114
|
+
}));
|
|
115
|
+
};
|
|
116
|
+
export const resultDetails = (result) => {
|
|
117
|
+
const throwable = result ? elements(result, "throwable", JAVA)[0] : undefined;
|
|
118
|
+
const trace = throwable?.text || undefined;
|
|
119
|
+
const reason = result ? elements(result, "reason")[0]?.text : undefined;
|
|
120
|
+
return { message: reason ?? trace?.split(/\r?\n/, 1)[0] ?? throwable?.attributes.type, trace };
|
|
121
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type RawTestStatus } from "@allurereport/reader-api";
|
|
2
|
+
import { type XmlElement } from "./xml.js";
|
|
3
|
+
export interface ReportNode {
|
|
4
|
+
name: string;
|
|
5
|
+
start?: number;
|
|
6
|
+
stop?: number;
|
|
7
|
+
duration?: number;
|
|
8
|
+
info: XmlElement[];
|
|
9
|
+
children: ReportNode[];
|
|
10
|
+
}
|
|
11
|
+
export declare const resultElement: (node: ReportNode) => XmlElement | undefined;
|
|
12
|
+
export declare const nodeStatus: (node: ReportNode) => RawTestStatus;
|
|
13
|
+
export declare const parseTime: (value: string | undefined) => number | undefined;
|
|
14
|
+
export declare const reportNodes: (root: XmlElement) => ReportNode[] | undefined;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { isDefined, isLiteral } from "@allurereport/reader-api";
|
|
2
|
+
import { CORE, EVENTS, HIERARCHY, elements } from "./xml.js";
|
|
3
|
+
const DEFAULT_TEST_NAME = "Default Test Name";
|
|
4
|
+
const statuses = new Map([
|
|
5
|
+
["SUCCESSFUL", "passed"],
|
|
6
|
+
["FAILED", "failed"],
|
|
7
|
+
["ERRORED", "broken"],
|
|
8
|
+
["SKIPPED", "skipped"],
|
|
9
|
+
["ABORTED", "skipped"],
|
|
10
|
+
]);
|
|
11
|
+
export const resultElement = (node) => node.info.filter((element) => element.namespace === CORE && element.name === "result").at(-1);
|
|
12
|
+
export const nodeStatus = (node) => {
|
|
13
|
+
const status = resultElement(node)?.attributes.status;
|
|
14
|
+
return (status === undefined ? undefined : statuses.get(status)) ?? "unknown";
|
|
15
|
+
};
|
|
16
|
+
export const parseTime = (value) => {
|
|
17
|
+
if (!value || !/^\d{4,}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(value)) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const time = Date.parse(value);
|
|
21
|
+
return Number.isFinite(time) ? time : undefined;
|
|
22
|
+
};
|
|
23
|
+
const durationMillis = (value, start) => {
|
|
24
|
+
const match = value?.match(/^(-)?P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/);
|
|
25
|
+
if (!match || !match.slice(2).some(isDefined) || (value?.includes("T") && !match.slice(5).some(isDefined))) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const sign = match[1] ? -1 : 1;
|
|
29
|
+
const [years, months, days, hours, minutes, seconds] = match.slice(2).map((part) => Number(part ?? 0));
|
|
30
|
+
if (start === undefined && (years || months)) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const reference = start ?? 0;
|
|
34
|
+
const end = new Date(reference);
|
|
35
|
+
const day = end.getUTCDate();
|
|
36
|
+
end.setUTCDate(1);
|
|
37
|
+
end.setUTCFullYear(end.getUTCFullYear() + sign * years);
|
|
38
|
+
end.setUTCMonth(end.getUTCMonth() + sign * months);
|
|
39
|
+
const lastDay = new Date(end);
|
|
40
|
+
lastDay.setUTCMonth(lastDay.getUTCMonth() + 1, 0);
|
|
41
|
+
end.setUTCDate(Math.min(day, lastDay.getUTCDate()));
|
|
42
|
+
const duration = end.getTime() - reference + sign * (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
|
|
43
|
+
return Number.isFinite(duration) ? duration : undefined;
|
|
44
|
+
};
|
|
45
|
+
const hierarchyNode = (element) => {
|
|
46
|
+
const start = parseTime(element.attributes.start);
|
|
47
|
+
const duration = durationMillis(element.attributes.duration, start);
|
|
48
|
+
return {
|
|
49
|
+
name: element.attributes.name || DEFAULT_TEST_NAME,
|
|
50
|
+
start,
|
|
51
|
+
duration,
|
|
52
|
+
stop: start === undefined || duration === undefined ? undefined : start + duration,
|
|
53
|
+
info: element.children,
|
|
54
|
+
children: elements(element, "child", HIERARCHY).map(hierarchyNode),
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const eventNodes = (root) => {
|
|
58
|
+
const events = root.children.filter((element) => element.namespace === EVENTS && isLiteral(element.name, ["started", "reported", "finished"]));
|
|
59
|
+
const nodes = new Map();
|
|
60
|
+
const parents = new Map();
|
|
61
|
+
for (const event of events.filter((element) => element.name === "started")) {
|
|
62
|
+
const id = event.attributes.id;
|
|
63
|
+
if (!id || nodes.has(id)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
nodes.set(id, {
|
|
67
|
+
name: event.attributes.name || DEFAULT_TEST_NAME,
|
|
68
|
+
start: parseTime(event.attributes.time),
|
|
69
|
+
info: [],
|
|
70
|
+
children: [],
|
|
71
|
+
});
|
|
72
|
+
parents.set(id, event.attributes.parentId);
|
|
73
|
+
}
|
|
74
|
+
for (const event of events) {
|
|
75
|
+
const id = event.attributes.id;
|
|
76
|
+
const node = id ? nodes.get(id) : undefined;
|
|
77
|
+
if (!node) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
node.info.push(...event.children);
|
|
81
|
+
if (event.name === "finished") {
|
|
82
|
+
const time = parseTime(event.attributes.time);
|
|
83
|
+
if (time !== undefined) {
|
|
84
|
+
node.stop = time;
|
|
85
|
+
node.duration = node.start === undefined ? undefined : time - node.start;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const roots = [];
|
|
90
|
+
const linkedParents = new Map();
|
|
91
|
+
for (const [id, node] of nodes) {
|
|
92
|
+
const parentId = parents.get(id);
|
|
93
|
+
const parent = parentId === undefined ? undefined : nodes.get(parentId);
|
|
94
|
+
let ancestor = parent;
|
|
95
|
+
while (ancestor && ancestor !== node) {
|
|
96
|
+
ancestor = linkedParents.get(ancestor);
|
|
97
|
+
}
|
|
98
|
+
if (!parent || ancestor === node) {
|
|
99
|
+
roots.push(node);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
parent.children.push(node);
|
|
103
|
+
linkedParents.set(node, parent);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return roots;
|
|
107
|
+
};
|
|
108
|
+
export const reportNodes = (root) => {
|
|
109
|
+
if (root.name === "events" && root.namespace === EVENTS) {
|
|
110
|
+
return eventNodes(root);
|
|
111
|
+
}
|
|
112
|
+
if (root.name === "execution" && root.namespace === HIERARCHY) {
|
|
113
|
+
return elements(root, "root", HIERARCHY).map(hierarchyNode);
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const CORE = "https://schemas.opentest4j.org/reporting/core/0.2.0";
|
|
2
|
+
export declare const EVENTS = "https://schemas.opentest4j.org/reporting/events/0.2.0";
|
|
3
|
+
export declare const HIERARCHY = "https://schemas.opentest4j.org/reporting/hierarchy/0.2.0";
|
|
4
|
+
export interface XmlElement {
|
|
5
|
+
name: string;
|
|
6
|
+
namespace?: string;
|
|
7
|
+
attributes: Partial<Record<string, string>>;
|
|
8
|
+
children: XmlElement[];
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const elements: (element: XmlElement, name: string, namespace?: string) => XmlElement[];
|
|
12
|
+
export declare const parseXml: (content: string) => XmlElement | undefined;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ensureArrayWithItems, ensureObjectWithProps, ensureString, isObject, isString, } from "@allurereport/reader-api";
|
|
2
|
+
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
3
|
+
export const CORE = "https://schemas.opentest4j.org/reporting/core/0.2.0";
|
|
4
|
+
export const EVENTS = "https://schemas.opentest4j.org/reporting/events/0.2.0";
|
|
5
|
+
export const HIERARCHY = "https://schemas.opentest4j.org/reporting/hierarchy/0.2.0";
|
|
6
|
+
const parser = new XMLParser({
|
|
7
|
+
preserveOrder: true,
|
|
8
|
+
ignoreAttributes: false,
|
|
9
|
+
attributeNamePrefix: "",
|
|
10
|
+
parseTagValue: false,
|
|
11
|
+
trimValues: false,
|
|
12
|
+
ignoreDeclaration: true,
|
|
13
|
+
ignorePiTags: true,
|
|
14
|
+
});
|
|
15
|
+
export const elements = (element, name, namespace = CORE) => element.children.filter((child) => child.name === name && child.namespace === namespace);
|
|
16
|
+
const convert = (nodes, inherited) => (ensureArrayWithItems(nodes, isObject) ?? []).flatMap((node) => {
|
|
17
|
+
const qualifiedName = Object.keys(node).find((key) => key !== ":@" && key !== "#text");
|
|
18
|
+
if (!qualifiedName) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const children = ensureArrayWithItems(node[qualifiedName], isObject);
|
|
22
|
+
if (!children) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const attributes = ensureObjectWithProps(node[":@"], isString) ?? {};
|
|
26
|
+
const namespaces = new Map(inherited);
|
|
27
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
28
|
+
if (value !== undefined && (name === "xmlns" || name.startsWith("xmlns:"))) {
|
|
29
|
+
namespaces.set(name === "xmlns" ? "" : name.slice(6), value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const colon = qualifiedName.indexOf(":");
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
name: colon === -1 ? qualifiedName : qualifiedName.slice(colon + 1),
|
|
36
|
+
namespace: namespaces.get(colon === -1 ? "" : qualifiedName.slice(0, colon)),
|
|
37
|
+
attributes,
|
|
38
|
+
children: convert(children, namespaces),
|
|
39
|
+
text: children.map((child) => ensureString(child["#text"]) ?? "").join(""),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
});
|
|
43
|
+
export const parseXml = (content) => {
|
|
44
|
+
const markupTokens = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<!DOCTYPE\b|<(?:[^<>"']|"[^"]*"|'[^']*')*>/gi;
|
|
45
|
+
for (const [token] of content.matchAll(markupTokens)) {
|
|
46
|
+
if (/^<!DOCTYPE\b/i.test(token)) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (XMLValidator.validate(content) !== true) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const parsed = parser.parse(content);
|
|
54
|
+
const roots = convert(parsed, new Map());
|
|
55
|
+
return roots.length === 1 ? roots[0] : undefined;
|
|
56
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allurereport/reader",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.22.1",
|
|
4
4
|
"description": "Collection of utilities which helps to process different kind of test results as Allure Results",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"allure",
|
|
@@ -21,28 +21,30 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "run clean && tsc --project ./tsconfig.json",
|
|
23
23
|
"clean": "rimraf ./dist",
|
|
24
|
-
"test": "
|
|
24
|
+
"test": "rimraf out && vp test run",
|
|
25
25
|
"test:unit": "yarn test",
|
|
26
|
-
"lint": "
|
|
27
|
-
"lint:fix": "
|
|
26
|
+
"lint": "vp lint --import-plugin src test features stories",
|
|
27
|
+
"lint:fix": "vp lint --import-plugin --fix src test features stories"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@allurereport/core-api": "3.
|
|
31
|
-
"@allurereport/plugin-api": "3.
|
|
32
|
-
"@allurereport/reader-api": "3.
|
|
30
|
+
"@allurereport/core-api": "3.22.1",
|
|
31
|
+
"@allurereport/plugin-api": "3.22.1",
|
|
32
|
+
"@allurereport/reader-api": "3.22.1",
|
|
33
33
|
"fast-xml-parser": "^5.11.1"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@allurereport/
|
|
36
|
+
"@allurereport/common-config": "3.22.1",
|
|
37
37
|
"@types/archiver": "^6.0.3",
|
|
38
38
|
"@types/node": "^20",
|
|
39
|
-
"@vitest/coverage-istanbul": "
|
|
39
|
+
"@vitest/coverage-istanbul": "5.0.4",
|
|
40
40
|
"@vitest/runner": "^2",
|
|
41
41
|
"allure-js-commons": "^3",
|
|
42
42
|
"allure-vitest": "^3",
|
|
43
43
|
"archiver": "^7.0.1",
|
|
44
44
|
"rimraf": "^6",
|
|
45
45
|
"typescript": "^5",
|
|
46
|
-
"
|
|
46
|
+
"vite": "@voidzero-dev/vite-plus-core@1.2.0",
|
|
47
|
+
"vite-plus": "1.2.0",
|
|
48
|
+
"vitest": "5.0.4"
|
|
47
49
|
}
|
|
48
50
|
}
|