@allurereport/reader 3.19.0 → 3.19.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.
@@ -0,0 +1,2 @@
1
+ import { type ResultsReader } from "@allurereport/reader-api";
2
+ export declare const cucumberMessages: ResultsReader;
@@ -0,0 +1,210 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { BufferResultFile, } from "@allurereport/reader-api";
3
+ import { isNonNullObject } from "../utils.js";
4
+ const readerId = "cucumber-messages";
5
+ const statusMap = new Map([
6
+ ["PASSED", "passed"],
7
+ ["FAILED", "failed"],
8
+ ["SKIPPED", "skipped"],
9
+ ["PENDING", "skipped"],
10
+ ["UNDEFINED", "broken"],
11
+ ["AMBIGUOUS", "broken"],
12
+ ]);
13
+ const statusOrder = ["failed", "broken", "unknown", "skipped", "passed"];
14
+ const stepKey = (executionId, stepId) => JSON.stringify([executionId, stepId]);
15
+ export const cucumberMessages = {
16
+ readerId: () => readerId,
17
+ matches: (data) => /\.(jsonl|ndjson)$/.test(data.getOriginalFileName()),
18
+ read: async (visitor, data) => {
19
+ const originalFileName = data.getOriginalFileName();
20
+ try {
21
+ const content = await data.asUtf8String();
22
+ if (!content?.trim())
23
+ return false;
24
+ const messages = content
25
+ .split(/\r?\n/)
26
+ .filter((line) => line.trim())
27
+ .map((line) => JSON.parse(line));
28
+ if (!messages.every(isNonNullObject))
29
+ return false;
30
+ return await processMessages(visitor, originalFileName, messages);
31
+ }
32
+ catch (error) {
33
+ console.error("error parsing", originalFileName, error);
34
+ return false;
35
+ }
36
+ },
37
+ };
38
+ const processMessages = async (visitor, originalFileName, messages) => {
39
+ const pickles = new Map();
40
+ const cases = new Map();
41
+ const hooks = new Map();
42
+ const starts = new Map();
43
+ const finishes = new Map();
44
+ const stepStarts = new Map();
45
+ const stepFinishes = new Map();
46
+ const attachments = new Map();
47
+ for (const message of messages) {
48
+ if (message.pickle)
49
+ pickles.set(message.pickle.id, message.pickle);
50
+ if (message.testCase)
51
+ cases.set(message.testCase.id, message.testCase);
52
+ if (message.hook)
53
+ hooks.set(message.hook.id, message.hook);
54
+ if (message.testCaseStarted)
55
+ starts.set(message.testCaseStarted.id, message.testCaseStarted);
56
+ if (message.testCaseFinished)
57
+ finishes.set(message.testCaseFinished.testCaseStartedId, message.testCaseFinished);
58
+ if (message.testStepStarted) {
59
+ const event = message.testStepStarted;
60
+ stepStarts.set(stepKey(event.testCaseStartedId, event.testStepId), event);
61
+ }
62
+ if (message.testStepFinished) {
63
+ const event = message.testStepFinished;
64
+ stepFinishes.set(stepKey(event.testCaseStartedId, event.testStepId), event);
65
+ }
66
+ const attachment = message.attachment;
67
+ if (attachment?.testCaseStartedId &&
68
+ typeof attachment.body === "string" &&
69
+ (attachment.contentEncoding === "IDENTITY" || attachment.contentEncoding === "BASE64")) {
70
+ const group = attachments.get(attachment.testCaseStartedId) ?? [];
71
+ group.push(attachment);
72
+ attachments.set(attachment.testCaseStartedId, group);
73
+ }
74
+ }
75
+ const gherkin = indexGherkin(messages);
76
+ const context = { readerId, metadata: { originalFileName } };
77
+ for (const execution of starts.values()) {
78
+ const testCase = cases.get(execution.testCaseId);
79
+ const pickle = testCase && pickles.get(testCase.pickleId);
80
+ if (!testCase || !pickle)
81
+ throw new Error(`Missing test case or pickle for execution ${execution.id}`);
82
+ const { scenario, feature } = pickle.astNodeIds.map((id) => gherkin.scenarios.get(id)).find(Boolean) ?? {};
83
+ const example = pickle.astNodeIds.map((id) => gherkin.examples.get(id)).find(Boolean);
84
+ const line = pickle.location?.line ?? example?.line ?? scenario?.location.line;
85
+ const name = pickle.name || "The scenario's name is not defined";
86
+ const finish = finishes.get(execution.id);
87
+ const result = {
88
+ uuid: randomUUID(),
89
+ name,
90
+ fullName: `${pickle.uri}${line === undefined ? "" : `:${line}`}#${name}`,
91
+ testCaseName: scenario?.name,
92
+ description: scenario?.description,
93
+ parameters: example?.parameters,
94
+ labels: [
95
+ ...(feature ? [{ name: "feature", value: feature }] : []),
96
+ ...pickle.tags.map(({ name: value }) => ({ name: "tag", value })),
97
+ ],
98
+ start: milliseconds(execution.timestamp),
99
+ stop: milliseconds(finish?.timestamp),
100
+ steps: [],
101
+ };
102
+ const pickleSteps = new Map(pickle.steps.map((step) => [step.id, step]));
103
+ const executionAttachments = attachments.get(execution.id) ?? [];
104
+ const allSteps = [];
105
+ const fixtures = [];
106
+ for (const testStep of testCase.testSteps) {
107
+ const key = stepKey(execution.id, testStep.id);
108
+ const pickleStep = testStep.pickleStepId ? pickleSteps.get(testStep.pickleStepId) : undefined;
109
+ const hook = testStep.hookId ? hooks.get(testStep.hookId) : undefined;
110
+ const keyword = pickleStep?.astNodeIds.map((id) => gherkin.keywords.get(id)).find((value) => value !== undefined);
111
+ const step = {
112
+ ...convertStep(stepStarts.get(key), stepFinishes.get(key)),
113
+ name: pickleStep ? `${keyword ?? ""}${pickleStep.text}` : (hook?.name ?? "Hook"),
114
+ steps: await readAttachments(visitor, executionAttachments.filter((attachment) => attachment.testStepId === testStep.id), pickleStep),
115
+ };
116
+ allSteps.push(step);
117
+ const fixtureType = hook?.type === "BEFORE_TEST_CASE" ? "before" : hook?.type === "AFTER_TEST_CASE" ? "after" : undefined;
118
+ if (fixtureType) {
119
+ fixtures.push({ ...step, type: fixtureType, testResults: [result.uuid] });
120
+ }
121
+ else {
122
+ result.steps.push(step);
123
+ }
124
+ }
125
+ result.steps.push(...(await readAttachments(visitor, executionAttachments.filter((attachment) => !attachment.testStepId))));
126
+ const worst = allSteps.sort((a, b) => statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status))[0];
127
+ result.status = finish ? (worst?.status ?? "unknown") : "unknown";
128
+ result.message = worst?.message;
129
+ result.trace = worst?.trace;
130
+ await visitor.visitTestResult(result, context);
131
+ for (const fixture of fixtures)
132
+ await visitor.visitTestFixtureResult(fixture, context);
133
+ }
134
+ return starts.size > 0;
135
+ };
136
+ const indexGherkin = (messages) => {
137
+ const scenarios = new Map();
138
+ const examples = new Map();
139
+ const keywords = new Map();
140
+ const visit = (children, feature) => {
141
+ for (const child of children) {
142
+ if (child.rule)
143
+ visit(child.rule.children, feature);
144
+ for (const step of child.scenario?.steps ?? child.background?.steps ?? [])
145
+ keywords.set(step.id, step.keyword);
146
+ if (!child.scenario)
147
+ continue;
148
+ const scenario = child.scenario;
149
+ scenarios.set(scenario.id, { scenario, feature });
150
+ for (const example of scenario.examples) {
151
+ for (const row of example.tableBody) {
152
+ examples.set(row.id, {
153
+ line: row.location.line,
154
+ parameters: example.tableHeader?.cells.map((cell, index) => ({ name: cell.value, value: row.cells[index]?.value })) ??
155
+ [],
156
+ });
157
+ }
158
+ }
159
+ }
160
+ };
161
+ for (const message of messages) {
162
+ const feature = message.gherkinDocument?.feature;
163
+ if (feature)
164
+ visit(feature.children, feature.name);
165
+ }
166
+ return { scenarios, examples, keywords };
167
+ };
168
+ const milliseconds = (timestamp) => {
169
+ if (!timestamp)
170
+ return undefined;
171
+ const value = Number(timestamp.seconds) * 1000 + timestamp.nanos / 1000000;
172
+ return Number.isFinite(value) ? value : undefined;
173
+ };
174
+ const convertStep = (started, finished) => {
175
+ const result = finished?.testStepResult;
176
+ const start = milliseconds(started?.timestamp);
177
+ const duration = milliseconds(result?.duration);
178
+ return {
179
+ type: "step",
180
+ start,
181
+ stop: start !== undefined && duration !== undefined ? undefined : milliseconds(finished?.timestamp),
182
+ duration,
183
+ status: statusMap.get(result?.status ?? "UNKNOWN") ?? "unknown",
184
+ message: result?.exception?.message ?? result?.message,
185
+ trace: result?.exception?.stackTrace ?? result?.message,
186
+ };
187
+ };
188
+ const readAttachments = async (visitor, attachments, step) => {
189
+ const result = [];
190
+ const docString = step?.argument?.docString;
191
+ if (docString) {
192
+ result.push(await visitAttachment(visitor, "Description", Buffer.from(docString.content), docString.mediaType ?? "text/plain"));
193
+ }
194
+ const table = step?.argument?.dataTable;
195
+ if (table) {
196
+ const csv = table.rows
197
+ .map(({ cells }) => cells.map(({ value }) => `"${value.replaceAll('"', '""')}"`).join(","))
198
+ .join("\n");
199
+ result.push(await visitAttachment(visitor, "Data", Buffer.from(csv), "text/csv"));
200
+ }
201
+ for (const attachment of attachments) {
202
+ result.push(await visitAttachment(visitor, attachment.fileName ?? "Attachment", Buffer.from(attachment.body, attachment.contentEncoding === "BASE64" ? "base64" : "utf8"), attachment.mediaType));
203
+ }
204
+ return result;
205
+ };
206
+ const visitAttachment = async (visitor, name, content, contentType) => {
207
+ const file = new BufferResultFile(content, randomUUID());
208
+ await visitor.visitAttachmentFile(file, { readerId });
209
+ return { type: "attachment", name, contentType, originalFileName: file.getOriginalFileName() };
210
+ };
@@ -0,0 +1,134 @@
1
+ export type CucumberTimestamp = {
2
+ seconds: number | string;
3
+ nanos: number;
4
+ };
5
+ export type CucumberLocation = {
6
+ line: number;
7
+ };
8
+ export type CucumberTableRow = {
9
+ cells: {
10
+ value: string;
11
+ }[];
12
+ };
13
+ export type CucumberPickleStep = {
14
+ id: string;
15
+ text: string;
16
+ astNodeIds: string[];
17
+ argument?: {
18
+ docString?: {
19
+ content: string;
20
+ mediaType?: string;
21
+ };
22
+ dataTable?: {
23
+ rows: CucumberTableRow[];
24
+ };
25
+ };
26
+ };
27
+ export type CucumberPickle = {
28
+ id: string;
29
+ uri: string;
30
+ name: string;
31
+ language: string;
32
+ location?: CucumberLocation;
33
+ astNodeIds: string[];
34
+ tags: {
35
+ name: string;
36
+ }[];
37
+ steps: CucumberPickleStep[];
38
+ };
39
+ export type CucumberGherkinStep = {
40
+ id: string;
41
+ keyword: string;
42
+ };
43
+ export type CucumberScenario = {
44
+ id: string;
45
+ name: string;
46
+ description: string;
47
+ location: CucumberLocation;
48
+ steps: CucumberGherkinStep[];
49
+ examples: {
50
+ tableHeader?: CucumberTableRow;
51
+ tableBody: (CucumberTableRow & {
52
+ id: string;
53
+ location: CucumberLocation;
54
+ })[];
55
+ }[];
56
+ };
57
+ export type CucumberGherkinChild = {
58
+ scenario?: CucumberScenario;
59
+ background?: {
60
+ steps: CucumberGherkinStep[];
61
+ };
62
+ rule?: {
63
+ children: CucumberGherkinChild[];
64
+ };
65
+ };
66
+ export type CucumberGherkinDocument = {
67
+ uri?: string;
68
+ feature?: {
69
+ name: string;
70
+ children: CucumberGherkinChild[];
71
+ };
72
+ };
73
+ export type CucumberHook = {
74
+ id: string;
75
+ name?: string;
76
+ type?: string;
77
+ };
78
+ export type CucumberTestCase = {
79
+ id: string;
80
+ pickleId: string;
81
+ testSteps: {
82
+ id: string;
83
+ pickleStepId?: string;
84
+ hookId?: string;
85
+ }[];
86
+ };
87
+ export type CucumberTestCaseStarted = {
88
+ id: string;
89
+ testCaseId: string;
90
+ attempt: number;
91
+ timestamp: CucumberTimestamp;
92
+ };
93
+ export type CucumberTestCaseFinished = {
94
+ testCaseStartedId: string;
95
+ timestamp: CucumberTimestamp;
96
+ willBeRetried: boolean;
97
+ };
98
+ export type CucumberTestStepEvent = {
99
+ testCaseStartedId: string;
100
+ testStepId: string;
101
+ timestamp: CucumberTimestamp;
102
+ };
103
+ export type CucumberTestStepResult = {
104
+ status: string;
105
+ duration?: CucumberTimestamp;
106
+ message?: string;
107
+ exception?: {
108
+ type: string;
109
+ message?: string;
110
+ stackTrace?: string;
111
+ };
112
+ };
113
+ export type CucumberTestStepFinished = CucumberTestStepEvent & {
114
+ testStepResult: CucumberTestStepResult;
115
+ };
116
+ export type CucumberAttachment = {
117
+ body: string;
118
+ contentEncoding: "IDENTITY" | "BASE64";
119
+ mediaType: string;
120
+ fileName?: string;
121
+ testCaseStartedId?: string;
122
+ testStepId?: string;
123
+ };
124
+ export type CucumberMessage = {
125
+ gherkinDocument?: CucumberGherkinDocument;
126
+ pickle?: CucumberPickle;
127
+ hook?: CucumberHook;
128
+ testCase?: CucumberTestCase;
129
+ testCaseStarted?: CucumberTestCaseStarted;
130
+ testCaseFinished?: CucumberTestCaseFinished;
131
+ testStepStarted?: CucumberTestStepEvent;
132
+ testStepFinished?: CucumberTestStepFinished;
133
+ attachment?: CucumberAttachment;
134
+ };
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./allure1/index.js";
2
2
  export * from "./allure2/index.js";
3
3
  export * from "./cucumberjson/index.js";
4
+ export * from "./cucumbermessages/index.js";
4
5
  export * from "./junitxml/index.js";
5
6
  export * from "./xcresult/index.js";
6
7
  export * from "./attachments/index.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./allure1/index.js";
2
2
  export * from "./allure2/index.js";
3
3
  export * from "./cucumberjson/index.js";
4
+ export * from "./cucumbermessages/index.js";
4
5
  export * from "./junitxml/index.js";
5
6
  export * from "./xcresult/index.js";
6
7
  export * from "./attachments/index.js";
@@ -1,6 +1,9 @@
1
- import * as console from "node:console";
1
+ import console from "node:console";
2
2
  import { randomUUID } from "node:crypto";
3
- import { BufferResultFile } from "@allurereport/reader-api";
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";
6
+ import { BufferResultFile, PathResultFile } from "@allurereport/reader-api";
4
7
  import { XMLParser } from "fast-xml-parser";
5
8
  import { ensureString } from "../utils.js";
6
9
  import { isEmptyElement, isStringAnyRecord, isStringAnyRecordArray } from "../xml-utils.js";
@@ -37,7 +40,8 @@ export const junitXml = {
37
40
  if (!isStringAnyRecord(parsed)) {
38
41
  return false;
39
42
  }
40
- return await parseRootElement(visitor, parsed);
43
+ const reportDir = dirname(data instanceof PathResultFile ? data.path : originalFileName);
44
+ return await parseRootElement(visitor, parsed, reportDir);
41
45
  }
42
46
  catch (e) {
43
47
  console.error("error parsing", originalFileName, e);
@@ -46,7 +50,7 @@ export const junitXml = {
46
50
  },
47
51
  readerId: () => readerId,
48
52
  };
49
- const parseRootElement = async (visitor, xml) => {
53
+ const parseRootElement = async (visitor, xml, reportDir) => {
50
54
  const { testsuite: testSuite } = xml;
51
55
  if (isEmptyElement(testSuite)) {
52
56
  return true;
@@ -67,26 +71,26 @@ const parseRootElement = async (visitor, xml) => {
67
71
  return false;
68
72
  }
69
73
  for (const testSuitesArrayElement of testSuitesArray) {
70
- await parseTestSuite(visitor, testSuitesArrayElement, true);
74
+ await parseTestSuite(visitor, testSuitesArrayElement, true, reportDir);
71
75
  }
72
76
  return true;
73
77
  }
74
78
  if (!isStringAnyRecord(testSuite)) {
75
79
  return false;
76
80
  }
77
- await parseTestSuite(visitor, testSuite, false);
81
+ await parseTestSuite(visitor, testSuite, false, reportDir);
78
82
  return true;
79
83
  };
80
- const parseTestSuite = async (visitor, testSuite, isAggregated) => {
84
+ const parseTestSuite = async (visitor, testSuite, isAggregated, reportDir) => {
81
85
  const { name, package: packageAttribute, testcase } = testSuite;
82
86
  if (!isStringAnyRecordArray(testcase)) {
83
87
  return;
84
88
  }
85
89
  for (const testcaseElement of testcase) {
86
- await parseTestCase(visitor, { name: ensureString(name), suitePackage: ensureString(packageAttribute) }, testcaseElement, isAggregated);
90
+ await parseTestCase(visitor, { name: ensureString(name), suitePackage: ensureString(packageAttribute) }, testcaseElement, isAggregated, reportDir);
87
91
  }
88
92
  };
89
- const parseTestCase = async (visitor, { name: suiteName, suitePackage }, testCase, isAggregated) => {
93
+ const parseTestCase = async (visitor, { name: suiteName, suitePackage }, testCase, isAggregated, reportDir) => {
90
94
  const { name: nameAttribute, failure, error, skipped, classname: classNameAttribute, time, "system-out": systemOutAttribute, "system-err": systemErrAttribute, } = testCase;
91
95
  const name = ensureString(nameAttribute);
92
96
  const className = ensureString(classNameAttribute);
@@ -100,12 +104,12 @@ const parseTestCase = async (visitor, { name: suiteName, suitePackage }, testCas
100
104
  status,
101
105
  message,
102
106
  trace,
103
- steps: await parseAttachments(visitor, systemOut, systemErr),
107
+ steps: await parseAttachments(visitor, reportDir, systemOut, systemErr),
104
108
  labels: convertLabels({ suitePackage, suiteName, className, isAggregated }),
105
109
  }, { readerId });
106
110
  };
107
111
  const convertFullName = (className, name) => (className && name ? `${className}.${name}` : undefined);
108
- const parseAttachments = async (visitor, systemOut, systemErr) => {
112
+ const parseAttachments = async (visitor, reportDir, systemOut, systemErr) => {
109
113
  const attachments = [];
110
114
  if (systemOut) {
111
115
  attachments.push(await visitPlainTextAttachment(visitor, STDOUT_ATTACHMENT_NAME, systemOut));
@@ -113,8 +117,46 @@ const parseAttachments = async (visitor, systemOut, systemErr) => {
113
117
  if (systemErr) {
114
118
  attachments.push(await visitPlainTextAttachment(visitor, STDERR_ATTACHMENT_NAME, systemErr));
115
119
  }
120
+ for (const [, path] of systemOut?.matchAll(/\[\[ATTACHMENT\|([^\r\n]*?)\]\]/g) ?? []) {
121
+ try {
122
+ const attachmentPath = await resolveAttachmentPath(reportDir, path);
123
+ const content = await readFile(attachmentPath, { flag: constants.O_RDONLY | constants.O_NOFOLLOW });
124
+ const file = new BufferResultFile(content, `${randomUUID()}${extname(path)}`);
125
+ await visitor.visitAttachmentFile(file, { readerId });
126
+ attachments.push({
127
+ type: "attachment",
128
+ name: basename(path),
129
+ contentType: file.getContentType(),
130
+ originalFileName: file.getOriginalFileName(),
131
+ });
132
+ }
133
+ catch (error) {
134
+ console.warn(`Unable to read JUnit attachment "${path}":`, error);
135
+ }
136
+ }
116
137
  return attachments;
117
138
  };
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
+ };
118
160
  const visitPlainTextAttachment = async (visitor, name, content) => {
119
161
  const fileName = randomUUID();
120
162
  await visitor.visitAttachmentFile(new BufferResultFile(Buffer.from(content), fileName), { readerId });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allurereport/reader",
3
- "version": "3.19.0",
3
+ "version": "3.19.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",
@@ -26,9 +26,9 @@
26
26
  "lint:fix": "oxlint --import-plugin --fix src test features stories"
27
27
  },
28
28
  "dependencies": {
29
- "@allurereport/core-api": "3.19.0",
30
- "@allurereport/plugin-api": "3.19.0",
31
- "@allurereport/reader-api": "3.19.0",
29
+ "@allurereport/core-api": "3.19.1",
30
+ "@allurereport/plugin-api": "3.19.1",
31
+ "@allurereport/reader-api": "3.19.1",
32
32
  "fast-xml-parser": "^5.11.1"
33
33
  },
34
34
  "devDependencies": {