Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(reader): cucumberjson improvements and fixes #42

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/report.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Plugin, PluginContext, PluginState, ReportFiles, ResultFile } from "@allurereport/plugin-api";
import { allure1, allure2, attachments, junitXml } from "@allurereport/reader";
import { allure1, allure2, attachments, cucumberjson, junitXml } from "@allurereport/reader";
import { PathResultFile, type ResultsReader } from "@allurereport/reader-api";
import console from "node:console";
import { randomUUID } from "node:crypto";
Expand Down Expand Up @@ -37,7 +37,7 @@ export class AllureReport {
constructor(opts: FullConfig) {
const {
name,
readers = [allure1, allure2, junitXml, attachments],
readers = [allure1, allure2, cucumberjson, junitXml, attachments],
plugins = [],
history,
known,
Expand Down
65 changes: 44 additions & 21 deletions packages/reader/src/cucumberjson/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import { randomUUID } from "node:crypto";
import { ensureArray, ensureInt, ensureString, isArray, isNonNullObject, isString } from "../utils.js";
import type {
CucumberDatatableRow,
CucumberDocString,
CucumberEmbedding,
CucumberFeature,
CucumberFeatureElement,
CucumberJsStepArgument,
CucumberStep,
CucumberTag,
} from "./model.js";
Expand Down Expand Up @@ -83,20 +85,21 @@ type PostProcessedStep = { preProcessedStep: PreProcessedStep; allureStep: RawTe
export const cucumberjson: ResultsReader = {
read: async (visitor, data) => {
const originalFileName = data.getOriginalFileName();
try {
const parsed = await data.asJson<CucumberFeature[]>();
if (parsed) {
let oneOrMoreFeaturesParsed = false;
for (const feature of parsed) {
oneOrMoreFeaturesParsed ||= await processFeature(visitor, originalFileName, feature);
if (originalFileName.endsWith(".json")) {
try {
const parsed = await data.asJson<CucumberFeature[]>();
if (parsed) {
let oneOrMoreFeaturesParsed = false;
for (const feature of parsed) {
oneOrMoreFeaturesParsed ||= await processFeature(visitor, originalFileName, feature);
}
return oneOrMoreFeaturesParsed;
}
return oneOrMoreFeaturesParsed;
} catch (e) {
console.error("error parsing", originalFileName, e);
return false;
}
} catch (e) {
console.error("error parsing", originalFileName, e);
return false;
}

return false;
},

Expand Down Expand Up @@ -158,30 +161,50 @@ const preProcessOneStep = async (visitor: ResultsVisitor, step: CucumberStep): P

const processStepAttachments = async (visitor: ResultsVisitor, step: CucumberStep) =>
[
await processStepDocStringAttachment(visitor, step),
await processStepDataTableAttachment(visitor, step),
await processStepDocStringAttachment(visitor, step.doc_string),
await processStepDataTableAttachment(visitor, step.rows),
...(await processCucumberJsStepArguments(visitor, step.arguments as CucumberJsStepArgument[])),
...(await processStepEmbeddingAttachments(visitor, step)),
].filter((s): s is RawTestAttachment => typeof s !== "undefined");

const processStepDocStringAttachment = async (
visitor: ResultsVisitor,
{ doc_string: docString }: CucumberStep,
): Promise<RawTestAttachment | undefined> => {
const processStepDocStringAttachment = async (visitor: ResultsVisitor, docString: CucumberDocString | undefined) => {
if (docString) {
const { value, content_type: contentType } = docString;
if (value && value.trim()) {
return await visitBufferAttachment(visitor, "Description", Buffer.from(value), contentType || "text/markdown");
const { value, content, content_type: contentType } = docString;
const resolvedValue = ensureString(value ?? content);
if (resolvedValue && resolvedValue.trim()) {
return await visitBufferAttachment(
visitor,
"Description",
Buffer.from(resolvedValue),
ensureString(contentType) || "text/markdown",
);
}
}
};

const processStepDataTableAttachment = async (visitor: ResultsVisitor, { rows }: CucumberStep) => {
const processStepDataTableAttachment = async (visitor: ResultsVisitor, rows: unknown) => {
if (isArray(rows)) {
const content = formatDataTable(rows);
return await visitBufferAttachment(visitor, "Data", Buffer.from(content), "text/csv");
}
};

const processCucumberJsStepArguments = async (visitor: ResultsVisitor, stepArguments: unknown) => {
const attachments = [];
if (isArray(stepArguments)) {
for (const stepArgument of stepArguments) {
if (isNonNullObject<CucumberJsStepArgument>(stepArgument)) {
if ("content" in stepArgument) {
attachments.push(await processStepDocStringAttachment(visitor, stepArgument));
} else if ("rows" in stepArgument) {
attachments.push(await processStepDataTableAttachment(visitor, stepArgument.rows));
}
}
}
}
return attachments;
};

const processStepEmbeddingAttachments = async (visitor: ResultsVisitor, { embeddings }: CucumberStep) => {
const attachments: RawTestAttachment[] = [];
const checkedEmbeddings = ensureArray(embeddings) ?? [];
Expand Down
4 changes: 4 additions & 0 deletions packages/reader/src/cucumberjson/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@ export type CucumberStep = {
output?: string[];
result: CucumberStepResult;
rows?: unknown; // CucumberDatatableRow[]
arguments?: unknown; // CucumberJsStepArgument[]; Cucumber-JS
};

export type CucumberDocString = {
content_type?: string;
line?: number;
value?: string;
content?: string; // Cucumber-JS
};

export type CucumberDatatableRow = {
Expand All @@ -69,3 +71,5 @@ export type CucumberEmbedding = {
mime_type: unknown; // string
name?: unknown; // string; Cucumber-JVM: https://github.com/cucumber/cucumber-jvm/pull/1693
};

export type CucumberJsStepArgument = CucumberDocString | { rows: CucumberDatatableRow[] };
219 changes: 219 additions & 0 deletions packages/reader/test/cucumberjson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import { readResults } from "./utils.js";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;

describe("cucumberjson reader", () => {
it("should ignore a file with no .json extension", async () => {
const visitor = await readResults(
cucumberjson,
{
"cucumberjsondata/reference/names/wellDefined.json": "cucumber",
},
false,
);
expect(visitor.visitTestResult).toHaveBeenCalledTimes(0);
});

// As implemented in https://github.com/cucumber/cucumber-ruby or https://github.com/cucumber/json-formatter (which
// uses cucumber-ruby as the reference for its tests).
describe("reference", () => {
Expand Down Expand Up @@ -761,6 +772,23 @@ describe("cucumberjson reader", () => {
});
});

it("should ignore a step's doc string with an ill-formed value", async () => {
const visitor = await readResults(cucumberjson, {
"cucumberjsondata/reference/docstrings/valueInvalid.json": "cucumber.json",
});

expect(visitor.visitTestResult).toHaveBeenCalledTimes(1);
expect(visitor.visitAttachmentFile).toHaveBeenCalledTimes(0);
const test = visitor.visitTestResult.mock.calls[0][0];
expect(test).toMatchObject({
steps: [
{
steps: [],
},
],
});
});

it("should ignore a step's empty doc string", async () => {
const visitor = await readResults(cucumberjson, {
"cucumberjsondata/reference/docstrings/emptyValue.json": "cucumber.json",
Expand Down Expand Up @@ -822,6 +850,33 @@ describe("cucumberjson reader", () => {
});
});

it("should parse a step's doc string with an ill-formed content type", async () => {
const visitor = await readResults(cucumberjson, {
"cucumberjsondata/reference/docstrings/contentTypeInvalid.json": "cucumber.json",
});

expect(visitor.visitTestResult).toHaveBeenCalledTimes(1);
expect(visitor.visitAttachmentFile).toHaveBeenCalledTimes(1);
const attachment = visitor.visitAttachmentFile.mock.calls[0][0];
const test = visitor.visitTestResult.mock.calls[0][0];
const content = await attachment.asUtf8String();
expect(content).toEqual("Lorem Ipsum");
expect(test).toMatchObject({
steps: [
{
steps: [
{
type: "attachment",
name: "Description",
contentType: "text/markdown", // fallback to markdown
originalFileName: attachment.getOriginalFileName(),
},
],
},
],
});
});

it("should parse a step's doc string with a content type", async () => {
const visitor = await readResults(cucumberjson, {
"cucumberjsondata/reference/docstrings/explicitContentType.json": "cucumber.json",
Expand Down Expand Up @@ -1294,4 +1349,168 @@ describe("cucumberjson reader", () => {
});
});
});

describe("cucumberjs", () => {
describe("step arguments", () => {
describe("docstrings", () => {
it("should parse a step's doc string", async () => {
const visitor = await readResults(cucumberjson, {
"cucumberjsondata/cucumberjs/stepArguments/docStringWellDefined.json": "cucumber.json",
});

expect(visitor.visitTestResult).toHaveBeenCalledTimes(1);
expect(visitor.visitAttachmentFile).toHaveBeenCalledTimes(1);
const attachment = visitor.visitAttachmentFile.mock.calls[0][0];
const test = visitor.visitTestResult.mock.calls[0][0];
const content = await attachment.asUtf8String();
expect(content).toEqual("Lorem Ipsum");
expect(test).toMatchObject({
steps: [
{
steps: [
{
type: "attachment",
name: "Description",
contentType: "text/markdown",
originalFileName: attachment.getOriginalFileName(),
},
],
},
],
});
});

it("should parse a step's data table", async () => {
const visitor = await readResults(cucumberjson, {
"cucumberjsondata/cucumberjs/stepArguments/dataTableWellDefined.json": "cucumber.json",
});

expect(visitor.visitTestResult).toHaveBeenCalledTimes(1);
expect(visitor.visitAttachmentFile).toHaveBeenCalledTimes(1);
const attachment = visitor.visitAttachmentFile.mock.calls[0][0];
const test = visitor.visitTestResult.mock.calls[0][0];
const content = await attachment.asUtf8String();
expect(content).toEqual('"col1","col2"\r\n"val1","val2"');
expect(test).toMatchObject({
steps: [
{
steps: [
{
type: "attachment",
name: "Data",
contentType: "text/csv",
originalFileName: attachment.getOriginalFileName(),
},
],
},
],
});
});

it("should parse multiple arguments", async () => {
const visitor = await readResults(cucumberjson, {
"cucumberjsondata/cucumberjs/stepArguments/twoWellDefinedArguments.json": "cucumber.json",
});

expect(visitor.visitTestResult).toHaveBeenCalledTimes(1);
expect(visitor.visitAttachmentFile).toHaveBeenCalledTimes(2);
const docStringAttachment = visitor.visitAttachmentFile.mock.calls[0][0];
const dataTableAttachment = visitor.visitAttachmentFile.mock.calls[1][0];
const test = visitor.visitTestResult.mock.calls[0][0];
const docStringContent = await docStringAttachment.asUtf8String();
const dataTableContent = await dataTableAttachment.asUtf8String();
expect(docStringContent).toEqual("Lorem Ipsum");
expect(dataTableContent).toEqual('"col1","col2"\r\n"val1","val2"');
expect(test).toMatchObject({
steps: [
{
steps: [
{
type: "attachment",
name: "Description",
contentType: "text/markdown",
originalFileName: docStringAttachment.getOriginalFileName(),
},
{
type: "attachment",
name: "Data",
contentType: "text/csv",
originalFileName: dataTableAttachment.getOriginalFileName(),
},
],
},
],
});
});

it("should ignore an invalid step arguments property", async () => {
const visitor = await readResults(cucumberjson, {
"cucumberjsondata/cucumberjs/stepArguments/argumentsPropertyInvalid.json": "cucumber.json",
});

expect(visitor.visitTestResult).toHaveBeenCalledTimes(1);
expect(visitor.visitAttachmentFile).toHaveBeenCalledTimes(0);
const test = visitor.visitTestResult.mock.calls[0][0];
expect(test).toMatchObject({
steps: [
{
steps: [],
},
],
});
});

it("should ignore a invalid step arguments", async () => {
const visitor = await readResults(cucumberjson, {
"cucumberjsondata/cucumberjs/stepArguments/argumentInvalid.json": "cucumber.json",
});

expect(visitor.visitTestResult).toHaveBeenCalledTimes(1);
expect(visitor.visitAttachmentFile).toHaveBeenCalledTimes(0);
const test = visitor.visitTestResult.mock.calls[0][0];
expect(test).toMatchObject({
steps: [
{
steps: [],
},
],
});
});

it("should ignore a step's doc string with a missing content", async () => {
const visitor = await readResults(cucumberjson, {
"cucumberjsondata/cucumberjs/stepArguments/docStringContentMissing.json": "cucumber.json",
});

expect(visitor.visitTestResult).toHaveBeenCalledTimes(1);
expect(visitor.visitAttachmentFile).toHaveBeenCalledTimes(0);
const test = visitor.visitTestResult.mock.calls[0][0];
expect(test).toMatchObject({
steps: [
{
steps: [],
},
],
});
});

it("should ignore a step's doc string with an ill-formed content", async () => {
const visitor = await readResults(cucumberjson, {
"cucumberjsondata/cucumberjs/stepArguments/docStringContentInvalid.json": "cucumber.json",
});

expect(visitor.visitTestResult).toHaveBeenCalledTimes(1);
expect(visitor.visitAttachmentFile).toHaveBeenCalledTimes(0);
const test = visitor.visitTestResult.mock.calls[0][0];
expect(test).toMatchObject({
steps: [
{
steps: [],
},
],
});
});
});
});
});
});
Loading
Loading