diff --git a/packages/core/src/report.ts b/packages/core/src/report.ts index 0e133a54..4d0325aa 100644 --- a/packages/core/src/report.ts +++ b/packages/core/src/report.ts @@ -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"; @@ -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, diff --git a/packages/reader/src/cucumberjson/index.ts b/packages/reader/src/cucumberjson/index.ts index 86fb4621..9397e97f 100644 --- a/packages/reader/src/cucumberjson/index.ts +++ b/packages/reader/src/cucumberjson/index.ts @@ -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"; @@ -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(); - 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(); + 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; }, @@ -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 => { +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(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) ?? []; diff --git a/packages/reader/src/cucumberjson/model.ts b/packages/reader/src/cucumberjson/model.ts index e47299b3..42229b50 100644 --- a/packages/reader/src/cucumberjson/model.ts +++ b/packages/reader/src/cucumberjson/model.ts @@ -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 = { @@ -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[] }; diff --git a/packages/reader/test/cucumberjson.test.ts b/packages/reader/test/cucumberjson.test.ts index 53b66231..b5c8b338 100644 --- a/packages/reader/test/cucumberjson.test.ts +++ b/packages/reader/test/cucumberjson.test.ts @@ -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", () => { @@ -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", @@ -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", @@ -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: [], + }, + ], + }); + }); + }); + }); + }); }); diff --git a/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/argumentInvalid.json b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/argumentInvalid.json new file mode 100644 index 00000000..c2bf997f --- /dev/null +++ b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/argumentInvalid.json @@ -0,0 +1,26 @@ +[ + { + "elements": [ + { + "id": "foo;bar", + "keyword": "Scenario", + "name": "Bar", + "steps": [ + { + "arguments": [1], + "keyword": "Then ", + "name": "pass", + "result": { + "status": "passed" + } + } + ], + "type": "scenario" + } + ], + "id": "foo", + "keyword": "Feature", + "name": "Foo", + "uri": "features/foo.feature" + } +] diff --git a/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/argumentsPropertyInvalid.json b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/argumentsPropertyInvalid.json new file mode 100644 index 00000000..15599098 --- /dev/null +++ b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/argumentsPropertyInvalid.json @@ -0,0 +1,26 @@ +[ + { + "elements": [ + { + "id": "foo;bar", + "keyword": "Scenario", + "name": "Bar", + "steps": [ + { + "arguments": "foobar", + "keyword": "Then ", + "name": "pass", + "result": { + "status": "passed" + } + } + ], + "type": "scenario" + } + ], + "id": "foo", + "keyword": "Feature", + "name": "Foo", + "uri": "features/foo.feature" + } +] diff --git a/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/dataTableWellDefined.json b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/dataTableWellDefined.json new file mode 100644 index 00000000..b163973c --- /dev/null +++ b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/dataTableWellDefined.json @@ -0,0 +1,27 @@ +[ + { + "elements": [ + { + "id": "foo;bar", + "keyword": "Scenario", + "name": "Bar", + "steps": [ + { + "keyword": "Then ", + "name": "pass", + "arguments": [ + { + "rows": [{ "cells": ["col1", "col2"] }, { "cells": ["val1", "val2"] }] + } + ] + } + ], + "type": "scenario" + } + ], + "id": "foo", + "keyword": "Feature", + "name": "Foo", + "uri": "features/foo.feature" + } +] diff --git a/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/docStringContentInvalid.json b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/docStringContentInvalid.json new file mode 100644 index 00000000..641899d5 --- /dev/null +++ b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/docStringContentInvalid.json @@ -0,0 +1,30 @@ +[ + { + "elements": [ + { + "id": "foo;bar", + "keyword": "Scenario", + "name": "Bar", + "steps": [ + { + "arguments": [ + { + "content": { "foo": "bar" } + } + ], + "keyword": "Then ", + "name": "pass", + "result": { + "status": "passed" + } + } + ], + "type": "scenario" + } + ], + "id": "foo", + "keyword": "Feature", + "name": "Foo", + "uri": "features/foo.feature" + } +] diff --git a/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/docStringContentMissing.json b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/docStringContentMissing.json new file mode 100644 index 00000000..de74e3b5 --- /dev/null +++ b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/docStringContentMissing.json @@ -0,0 +1,26 @@ +[ + { + "elements": [ + { + "id": "foo;bar", + "keyword": "Scenario", + "name": "Bar", + "steps": [ + { + "arguments": [{}], + "keyword": "Then ", + "name": "pass", + "result": { + "status": "passed" + } + } + ], + "type": "scenario" + } + ], + "id": "foo", + "keyword": "Feature", + "name": "Foo", + "uri": "features/foo.feature" + } +] diff --git a/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/docStringWellDefined.json b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/docStringWellDefined.json new file mode 100644 index 00000000..b305f966 --- /dev/null +++ b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/docStringWellDefined.json @@ -0,0 +1,30 @@ +[ + { + "elements": [ + { + "id": "foo;bar", + "keyword": "Scenario", + "name": "Bar", + "steps": [ + { + "arguments": [ + { + "content": "Lorem Ipsum" + } + ], + "keyword": "Then ", + "name": "pass", + "result": { + "status": "passed" + } + } + ], + "type": "scenario" + } + ], + "id": "foo", + "keyword": "Feature", + "name": "Foo", + "uri": "features/foo.feature" + } +] diff --git a/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/twoWellDefinedArguments.json b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/twoWellDefinedArguments.json new file mode 100644 index 00000000..75fbe4ca --- /dev/null +++ b/packages/reader/test/resources/cucumberjsondata/cucumberjs/stepArguments/twoWellDefinedArguments.json @@ -0,0 +1,30 @@ +[ + { + "elements": [ + { + "id": "foo;bar", + "keyword": "Scenario", + "name": "Bar", + "steps": [ + { + "keyword": "Then ", + "name": "pass", + "arguments": [ + { + "content": "Lorem Ipsum" + }, + { + "rows": [{ "cells": ["col1", "col2"] }, { "cells": ["val1", "val2"] }] + } + ] + } + ], + "type": "scenario" + } + ], + "id": "foo", + "keyword": "Feature", + "name": "Foo", + "uri": "features/foo.feature" + } +] diff --git a/packages/reader/test/resources/cucumberjsondata/reference/docstrings/contentTypeInvalid.json b/packages/reader/test/resources/cucumberjsondata/reference/docstrings/contentTypeInvalid.json new file mode 100644 index 00000000..7d4fc09e --- /dev/null +++ b/packages/reader/test/resources/cucumberjsondata/reference/docstrings/contentTypeInvalid.json @@ -0,0 +1,29 @@ +[ + { + "elements": [ + { + "id": "foo;bar", + "keyword": "Scenario", + "name": "Bar", + "steps": [ + { + "doc_string": { + "value": "Lorem Ipsum", + "content_type": { "foo": "bar" } + }, + "keyword": "Then ", + "name": "pass", + "result": { + "status": "passed" + } + } + ], + "type": "scenario" + } + ], + "id": "foo", + "keyword": "Feature", + "name": "Foo", + "uri": "features/foo.feature" + } +] diff --git a/packages/reader/test/resources/cucumberjsondata/reference/docstrings/valueInvalid.json b/packages/reader/test/resources/cucumberjsondata/reference/docstrings/valueInvalid.json new file mode 100644 index 00000000..aae0ce09 --- /dev/null +++ b/packages/reader/test/resources/cucumberjsondata/reference/docstrings/valueInvalid.json @@ -0,0 +1,28 @@ +[ + { + "elements": [ + { + "id": "foo;bar", + "keyword": "Scenario", + "name": "Bar", + "steps": [ + { + "doc_string": { + "value": { "foo": "bar" } + }, + "keyword": "Then ", + "name": "pass", + "result": { + "status": "passed" + } + } + ], + "type": "scenario" + } + ], + "id": "foo", + "keyword": "Feature", + "name": "Foo", + "uri": "features/foo.feature" + } +] diff --git a/packages/reader/test/utils.ts b/packages/reader/test/utils.ts index 95dbf36c..29b1c116 100644 --- a/packages/reader/test/utils.ts +++ b/packages/reader/test/utils.ts @@ -31,14 +31,18 @@ export const mockVisitor: () => Mocked = () => ({ visitTestFixtureResult: vi.fn(), }); -export const readResults = async (reader: ResultsReader, files: Record = {}) => { +export const readResults = async ( + reader: ResultsReader, + files: Record = {}, + result: boolean = true, +) => { return step("readResults", async () => { const visitor = mockVisitor(); for (const filesKey in files) { const resultFile = await readResourceAsResultFile(filesKey, files[filesKey]); await attachResultFile(resultFile); const read = await reader.read(visitor, resultFile); - expect(read).toBe(true); + expect(read).toBe(result); } return visitor; });