diff --git a/.github/workflows/labels-verify.yml b/.github/workflows/labels-verify.yml index 077315bba..c9980ead9 100644 --- a/.github/workflows/labels-verify.yml +++ b/.github/workflows/labels-verify.yml @@ -2,7 +2,7 @@ name: "Verify type labels" on: pull_request: - types: [ opened, labeled, unlabeled, synchronize ] + types: [ labeled, unlabeled ] jobs: triage: diff --git a/allure-cucumber7-jvm/build.gradle.kts b/allure-cucumber7-jvm/build.gradle.kts new file mode 100644 index 000000000..b6104c0aa --- /dev/null +++ b/allure-cucumber7-jvm/build.gradle.kts @@ -0,0 +1,32 @@ +description = "Allure CucumberJVM 7.0" + +val cucumberVersion = "7.0.0" +val cucumberGherkinVersion = "22.0.0" + +dependencies { + api(project(":allure-java-commons")) + compileOnly("io.cucumber:cucumber-plugin:$cucumberVersion") + implementation("io.cucumber:gherkin:$cucumberGherkinVersion") + testImplementation("io.cucumber:gherkin:$cucumberGherkinVersion") + testImplementation("io.cucumber:cucumber-core:$cucumberVersion") + testImplementation("io.cucumber:cucumber-java:$cucumberVersion") + testImplementation("commons-io:commons-io") + testImplementation("io.github.glytching:junit-extensions") + testImplementation("org.assertj:assertj-core") + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.slf4j:slf4j-simple") + testImplementation(project(":allure-java-commons-test")) + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") +} + +tasks.jar { + manifest { + attributes(mapOf( + "Automatic-Module-Name" to "io.qameta.allure.cucumber7jvm" + )) + } +} + +tasks.test { + useJUnitPlatform() +} diff --git a/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/AllureCucumber7Jvm.java b/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/AllureCucumber7Jvm.java new file mode 100644 index 000000000..467f533af --- /dev/null +++ b/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/AllureCucumber7Jvm.java @@ -0,0 +1,440 @@ +/* + * Copyright 2019 Qameta Software OÜ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.qameta.allure.cucumber7jvm; + +import io.cucumber.messages.types.Examples; +import io.cucumber.messages.types.Feature; +import io.cucumber.messages.types.Scenario; +import io.cucumber.messages.types.TableRow; +import io.cucumber.plugin.ConcurrentEventListener; +import io.cucumber.plugin.event.DataTableArgument; +import io.cucumber.plugin.event.EmbedEvent; +import io.cucumber.plugin.event.EventHandler; +import io.cucumber.plugin.event.EventPublisher; +import io.cucumber.plugin.event.HookTestStep; +import io.cucumber.plugin.event.HookType; +import io.cucumber.plugin.event.PickleStepTestStep; +import io.cucumber.plugin.event.Result; +import io.cucumber.plugin.event.StepArgument; +import io.cucumber.plugin.event.TestCase; +import io.cucumber.plugin.event.TestCaseFinished; +import io.cucumber.plugin.event.TestCaseStarted; +import io.cucumber.plugin.event.TestSourceRead; +import io.cucumber.plugin.event.TestStepFinished; +import io.cucumber.plugin.event.TestStepStarted; +import io.cucumber.plugin.event.WriteEvent; +import io.qameta.allure.Allure; +import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.cucumber7jvm.testsourcemodel.TestSourcesModelProxy; +import io.qameta.allure.model.FixtureResult; +import io.qameta.allure.model.Parameter; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StatusDetails; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.model.TestResultContainer; + +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Deque; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static io.qameta.allure.util.ResultsUtils.createParameter; +import static io.qameta.allure.util.ResultsUtils.getStatus; +import static io.qameta.allure.util.ResultsUtils.getStatusDetails; +import static io.qameta.allure.util.ResultsUtils.md5; + +/** + * Allure plugin for Cucumber JVM 7.0. + */ +@SuppressWarnings({ + "ClassDataAbstractionCoupling", + "ClassFanOutComplexity", + "PMD.ExcessiveImports", + "PMD.GodClass", +}) +public class AllureCucumber7Jvm implements ConcurrentEventListener { + + private final AllureLifecycle lifecycle; + + private final ConcurrentHashMap scenarioUuids = new ConcurrentHashMap<>(); + private final TestSourcesModelProxy testSources = new TestSourcesModelProxy(); + + private final ThreadLocal currentFeature = new InheritableThreadLocal<>(); + private final ThreadLocal currentFeatureFile = new InheritableThreadLocal<>(); + private final ThreadLocal currentTestCase = new InheritableThreadLocal<>(); + private final ThreadLocal currentContainer = new InheritableThreadLocal<>(); + private final ThreadLocal forbidTestCaseStatusChange = new InheritableThreadLocal<>(); + + private final EventHandler featureStartedHandler = this::handleFeatureStartedHandler; + private final EventHandler caseStartedHandler = this::handleTestCaseStarted; + private final EventHandler caseFinishedHandler = this::handleTestCaseFinished; + private final EventHandler stepStartedHandler = this::handleTestStepStarted; + private final EventHandler stepFinishedHandler = this::handleTestStepFinished; + private final EventHandler writeEventHandler = this::handleWriteEvent; + private final EventHandler embedEventHandler = this::handleEmbedEvent; + + private static final String TXT_EXTENSION = ".txt"; + private static final String TEXT_PLAIN = "text/plain"; + + @SuppressWarnings("unused") + public AllureCucumber7Jvm() { + this(Allure.getLifecycle()); + } + + public AllureCucumber7Jvm(final AllureLifecycle lifecycle) { + this.lifecycle = lifecycle; + } + + /* + Event Handlers + */ + @Override + public void setEventPublisher(final EventPublisher publisher) { + publisher.registerHandlerFor(TestSourceRead.class, featureStartedHandler); + + publisher.registerHandlerFor(TestCaseStarted.class, caseStartedHandler); + publisher.registerHandlerFor(TestCaseFinished.class, caseFinishedHandler); + + publisher.registerHandlerFor(TestStepStarted.class, stepStartedHandler); + publisher.registerHandlerFor(TestStepFinished.class, stepFinishedHandler); + + publisher.registerHandlerFor(WriteEvent.class, writeEventHandler); + publisher.registerHandlerFor(EmbedEvent.class, embedEventHandler); + } + + private void handleFeatureStartedHandler(final TestSourceRead event) { + testSources.addTestSourceReadEvent(event.getUri(), event); + } + + private void handleTestCaseStarted(final TestCaseStarted event) { + currentFeatureFile.set(event.getTestCase().getUri()); + currentFeature.set(testSources.getFeature(currentFeatureFile.get())); + currentTestCase.set(event.getTestCase()); + currentContainer.set(UUID.randomUUID().toString()); + forbidTestCaseStatusChange.set(false); + + final Deque tags = new LinkedList<>(currentTestCase.get().getTags()); + + final Feature feature = currentFeature.get(); + final LabelBuilder labelBuilder = new LabelBuilder(feature, currentTestCase.get(), tags); + + final String name = currentTestCase.get().getName(); + final String featureName = feature.getName(); + + final TestResult result = new TestResult() + .setUuid(getTestCaseUuid(currentTestCase.get())) + .setHistoryId(getHistoryId(currentTestCase.get())) + .setFullName(featureName + ": " + name) + .setName(name) + .setLabels(labelBuilder.getScenarioLabels()) + .setLinks(labelBuilder.getScenarioLinks()); + + final Scenario scenarioDefinition = + testSources.getScenarioDefinition( + currentFeatureFile.get(), + currentTestCase.get().getLocation().getLine() + ); + + if (scenarioDefinition.getExamples() != null) { + result.setParameters( + getExamplesAsParameters(scenarioDefinition, currentTestCase.get()) + ); + } + + final String description = Stream.of(feature.getDescription(), scenarioDefinition.getDescription()) + .filter(Objects::nonNull) + .filter(s -> !s.isEmpty()) + .collect(Collectors.joining("\n")); + + if (!description.isEmpty()) { + result.setDescription(description); + } + + final TestResultContainer resultContainer = new TestResultContainer() + .setName(String.format("%s: %s", scenarioDefinition.getKeyword(), scenarioDefinition.getName())) + .setUuid(getTestContainerUuid()) + .setChildren(Collections.singletonList(getTestCaseUuid(currentTestCase.get()))); + + lifecycle.scheduleTestCase(result); + lifecycle.startTestContainer(getTestContainerUuid(), resultContainer); + lifecycle.startTestCase(getTestCaseUuid(currentTestCase.get())); + } + + private void handleTestCaseFinished(final TestCaseFinished event) { + + final String uuid = getTestCaseUuid(event.getTestCase()); + final Optional details = getStatusDetails(event.getResult().getError()); + details.ifPresent(statusDetails -> lifecycle.updateTestCase( + uuid, + testResult -> testResult.setStatusDetails(statusDetails) + )); + lifecycle.stopTestCase(uuid); + lifecycle.stopTestContainer(getTestContainerUuid()); + lifecycle.writeTestCase(uuid); + lifecycle.writeTestContainer(getTestContainerUuid()); + } + + private void handleTestStepStarted(final TestStepStarted event) { + if (event.getTestStep() instanceof PickleStepTestStep) { + final PickleStepTestStep pickleStep = (PickleStepTestStep) event.getTestStep(); + final String stepKeyword = Optional.ofNullable( + testSources.getKeywordFromSource(currentFeatureFile.get(), pickleStep.getStep().getLine()) + ).orElse("UNDEFINED"); + + final StepResult stepResult = new StepResult() + .setName(String.format("%s %s", stepKeyword, pickleStep.getStep().getText())) + .setStart(System.currentTimeMillis()); + + lifecycle.startStep(getTestCaseUuid(currentTestCase.get()), getStepUuid(pickleStep), stepResult); + + final StepArgument stepArgument = pickleStep.getStep().getArgument(); + if (stepArgument instanceof DataTableArgument) { + final DataTableArgument dataTableArgument = (DataTableArgument) stepArgument; + createDataTableAttachment(dataTableArgument); + } + } else if (event.getTestStep() instanceof HookTestStep) { + initHook((HookTestStep) event.getTestStep()); + } + } + + private void initHook(final HookTestStep hook) { + + final FixtureResult hookResult = new FixtureResult() + .setName(hook.getCodeLocation()) + .setStart(System.currentTimeMillis()); + + if (hook.getHookType() == HookType.BEFORE) { + lifecycle.startPrepareFixture(getTestContainerUuid(), getHookStepUuid(hook), hookResult); + } else { + lifecycle.startTearDownFixture(getTestContainerUuid(), getHookStepUuid(hook), hookResult); + } + + } + + private void handleTestStepFinished(final TestStepFinished event) { + if (event.getTestStep() instanceof HookTestStep) { + handleHookStep(event); + } else { + handlePickleStep(event); + } + } + + private void handleWriteEvent(final WriteEvent event) { + lifecycle.addAttachment( + "Text output", + TEXT_PLAIN, + TXT_EXTENSION, + Objects.toString(event.getText()).getBytes(StandardCharsets.UTF_8) + ); + } + + private void handleEmbedEvent(final EmbedEvent event) { + lifecycle.addAttachment(event.name, event.getMediaType(), null, new ByteArrayInputStream(event.getData())); + } + + /* + Utility Methods + */ + + private String getTestContainerUuid() { + return currentContainer.get(); + } + + private String getTestCaseUuid(final TestCase testCase) { + return scenarioUuids.computeIfAbsent(getHistoryId(testCase), it -> UUID.randomUUID().toString()); + } + + private String getStepUuid(final PickleStepTestStep step) { + return currentFeature.get().getName() + getTestCaseUuid(currentTestCase.get()) + + step.getStep().getText() + step.getStep().getLine(); + } + + private String getHookStepUuid(final HookTestStep step) { + return currentFeature.get().getName() + getTestCaseUuid(currentTestCase.get()) + + step.getHookType().toString() + step.getCodeLocation(); + } + + private String getHistoryId(final TestCase testCase) { + final String testCaseLocation = testCase.getUri().toString() + .substring(testCase.getUri().toString().lastIndexOf('/') + 1) + + ":" + testCase.getLocation().getLine(); + return md5(testCaseLocation); + } + + private Status translateTestCaseStatus(final Result testCaseResult) { + switch (testCaseResult.getStatus()) { + case FAILED: + return getStatus(testCaseResult.getError()) + .orElse(Status.FAILED); + case PASSED: + return Status.PASSED; + case SKIPPED: + case PENDING: + return Status.SKIPPED; + case AMBIGUOUS: + case UNDEFINED: + default: + return null; + } + } + + private List getExamplesAsParameters( + final Scenario scenario, final TestCase localCurrentTestCase + ) { + final Optional maybeExample = + scenario.getExamples().stream() + .filter(example -> example.getTableBody().stream() + .anyMatch(row -> row.getLocation().getLine() + == localCurrentTestCase.getLocation().getLine()) + ) + .findFirst(); + + if (!maybeExample.isPresent()) { + return Collections.emptyList(); + } + + final Examples examples = maybeExample.get(); + + final Optional maybeRow = examples.getTableBody().stream() + .filter(example -> example.getLocation().getLine() == localCurrentTestCase.getLocation().getLine()) + .findFirst(); + + if (!maybeRow.isPresent()) { + return Collections.emptyList(); + } + + final TableRow row = maybeRow.get(); + + return IntStream.range(0, examples.getTableHeader().getCells().size()) + .mapToObj(index -> { + final String name = examples.getTableHeader().getCells().get(index).getValue(); + final String value = row.getCells().get(index).getValue(); + return createParameter(name, value); + }) + .collect(Collectors.toList()); + } + + private void createDataTableAttachment(final DataTableArgument dataTableArgument) { + final List> rowsInTable = dataTableArgument.cells(); + final StringBuilder dataTableCsv = new StringBuilder(); + for (List columns : rowsInTable) { + if (!columns.isEmpty()) { + for (int i = 0; i < columns.size(); i++) { + if (i == columns.size() - 1) { + dataTableCsv.append(columns.get(i)); + } else { + dataTableCsv.append(columns.get(i)); + dataTableCsv.append('\t'); + } + } + dataTableCsv.append('\n'); + } + } + final String attachmentSource = lifecycle + .prepareAttachment("Data table", "text/tab-separated-values", "csv"); + lifecycle.writeAttachment(attachmentSource, + new ByteArrayInputStream(dataTableCsv.toString().getBytes(StandardCharsets.UTF_8))); + } + + private void handleHookStep(final TestStepFinished event) { + final HookTestStep hookStep = (HookTestStep) event.getTestStep(); + final String uuid = getHookStepUuid(hookStep); + final FixtureResult fixtureResult = new FixtureResult().setStatus(translateTestCaseStatus(event.getResult())); + + if (!Status.PASSED.equals(fixtureResult.getStatus())) { + final TestResult testResult = new TestResult().setStatus(translateTestCaseStatus(event.getResult())); + final StatusDetails statusDetails = getStatusDetails(event.getResult().getError()) + .orElseGet(StatusDetails::new); + + final String errorMessage = event.getResult().getError() == null ? hookStep.getHookType() + .name() + " is failed." : hookStep.getHookType() + .name() + " is failed: " + event.getResult().getError().getLocalizedMessage(); + statusDetails.setMessage(errorMessage); + + if (hookStep.getHookType() == HookType.BEFORE) { + final TagParser tagParser = new TagParser(currentFeature.get(), currentTestCase.get()); + statusDetails + .setFlaky(tagParser.isFlaky()) + .setMuted(tagParser.isMuted()) + .setKnown(tagParser.isKnown()); + testResult.setStatus(Status.SKIPPED); + updateTestCaseStatus(testResult.getStatus()); + forbidTestCaseStatusChange.set(true); + } else { + testResult.setStatus(Status.BROKEN); + updateTestCaseStatus(testResult.getStatus()); + } + fixtureResult.setStatusDetails(statusDetails); + } + + lifecycle.updateFixture(uuid, result -> result.setStatus(fixtureResult.getStatus()) + .setStatusDetails(fixtureResult.getStatusDetails())); + lifecycle.stopFixture(uuid); + } + + private void handlePickleStep(final TestStepFinished event) { + + final Status stepStatus = translateTestCaseStatus(event.getResult()); + final StatusDetails statusDetails; + if (event.getResult().getStatus() == io.cucumber.plugin.event.Status.UNDEFINED) { + updateTestCaseStatus(Status.PASSED); + + statusDetails = + getStatusDetails(new IllegalStateException("Undefined Step. Please add step definition")) + .orElse(new StatusDetails()); + lifecycle.updateTestCase(getTestCaseUuid(currentTestCase.get()), scenarioResult -> + scenarioResult + .setStatusDetails(statusDetails)); + } else { + statusDetails = + getStatusDetails(event.getResult().getError()) + .orElse(new StatusDetails()); + updateTestCaseStatus(stepStatus); + } + + if (!Status.PASSED.equals(stepStatus) && stepStatus != null) { + forbidTestCaseStatusChange.set(true); + } + + final TagParser tagParser = new TagParser(currentFeature.get(), currentTestCase.get()); + statusDetails + .setFlaky(tagParser.isFlaky()) + .setMuted(tagParser.isMuted()) + .setKnown(tagParser.isKnown()); + + lifecycle.updateStep(getStepUuid((PickleStepTestStep) event.getTestStep()), + stepResult -> stepResult.setStatus(stepStatus).setStatusDetails(statusDetails)); + lifecycle.stopStep(getStepUuid((PickleStepTestStep) event.getTestStep())); + } + + private void updateTestCaseStatus(final Status status) { + if (!forbidTestCaseStatusChange.get()) { + lifecycle.updateTestCase(getTestCaseUuid(currentTestCase.get()), + result -> result.setStatus(status)); + } + } +} diff --git a/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/LabelBuilder.java b/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/LabelBuilder.java new file mode 100644 index 000000000..961138543 --- /dev/null +++ b/allure-cucumber7-jvm/src/main/java/io/qameta/allure/cucumber7jvm/LabelBuilder.java @@ -0,0 +1,192 @@ +/* + * Copyright 2019 Qameta Software OÜ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.qameta.allure.cucumber7jvm; + +import io.cucumber.messages.types.Feature; +import io.cucumber.plugin.event.TestCase; +import io.qameta.allure.model.Label; +import io.qameta.allure.model.Link; +import io.qameta.allure.util.ResultsUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static io.qameta.allure.util.ResultsUtils.createFeatureLabel; +import static io.qameta.allure.util.ResultsUtils.createFrameworkLabel; +import static io.qameta.allure.util.ResultsUtils.createHostLabel; +import static io.qameta.allure.util.ResultsUtils.createLabel; +import static io.qameta.allure.util.ResultsUtils.createLanguageLabel; +import static io.qameta.allure.util.ResultsUtils.createStoryLabel; +import static io.qameta.allure.util.ResultsUtils.createSuiteLabel; +import static io.qameta.allure.util.ResultsUtils.createTestClassLabel; +import static io.qameta.allure.util.ResultsUtils.createThreadLabel; + +/** + * Scenario labels and links builder. + */ +@SuppressWarnings({"CyclomaticComplexity", "PMD.CyclomaticComplexity", "PMD.NcssCount", "MultipleStringLiterals"}) +class LabelBuilder { + private static final Logger LOGGER = LoggerFactory.getLogger(LabelBuilder.class); + private static final String COMPOSITE_TAG_DELIMITER = "="; + + private static final String SEVERITY = "@SEVERITY"; + private static final String ISSUE_LINK = "@ISSUE"; + private static final String TMS_LINK = "@TMSLINK"; + private static final String PLAIN_LINK = "@LINK"; + + private final List