@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 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:
@@ -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";
@@ -1,10 +1,9 @@
1
1
  import console from "node:console";
2
2
  import { randomUUID } from "node:crypto";
3
- import { constants } from "node:fs";
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 attachmentPath = await resolveAttachmentPath(reportDir, path);
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,2 @@
1
+ import { type ResultsReader } from "@allurereport/reader-api";
2
+ export declare const openTestReporting: ResultsReader;
@@ -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.21.0",
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": "vitest run",
24
+ "test": "rimraf out && vp test run",
25
25
  "test:unit": "yarn test",
26
- "lint": "oxlint --import-plugin src test features stories",
27
- "lint:fix": "oxlint --import-plugin --fix src test features stories"
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.21.0",
31
- "@allurereport/plugin-api": "3.21.0",
32
- "@allurereport/reader-api": "3.21.0",
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/test-config": "3.21.0",
36
+ "@allurereport/common-config": "3.22.1",
37
37
  "@types/archiver": "^6.0.3",
38
38
  "@types/node": "^20",
39
- "@vitest/coverage-istanbul": "^4.1.0",
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
- "vitest": "^4.1.0"
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
  }